chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# Package marker for mirrored tests.
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Unit tests for the miss triage store and benchmark scenario conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from core.domain.feedback import (
|
||||
MissRecord,
|
||||
MissTaxonomy,
|
||||
compute_recurrence,
|
||||
compute_stats,
|
||||
load_misses,
|
||||
record_miss,
|
||||
to_benchmark_scenario,
|
||||
)
|
||||
from core.domain.feedback.misses import (
|
||||
export_scenarios,
|
||||
filter_top_misses,
|
||||
misses_path,
|
||||
parse_since,
|
||||
taxonomy_choices,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def opensre_home(monkeypatch, tmp_path: Path) -> Path:
|
||||
home = tmp_path / ".opensre"
|
||||
monkeypatch.setattr("config.constants.OPENSRE_HOME_DIR", home)
|
||||
return home
|
||||
|
||||
|
||||
# ── taxonomy enum surface ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_taxonomy_choices_includes_all_buckets() -> None:
|
||||
keys = {key for key, _ in taxonomy_choices()}
|
||||
assert keys == {t.value for t in MissTaxonomy}
|
||||
|
||||
|
||||
# ── record_miss / load_misses round-trip ──────────────────────────────────────
|
||||
|
||||
|
||||
def _feedback_dict(**overrides) -> dict:
|
||||
base = {
|
||||
"feedback_id": "fb-001",
|
||||
"timestamp": "2026-06-02T10:00:00+00:00",
|
||||
"run_id": "run-001",
|
||||
"alert_name": "checkout-api 5xx",
|
||||
"rating": "inaccurate",
|
||||
"note": "missed the canary deploy from 09:42",
|
||||
"root_cause": "The investigation concluded a DB outage.",
|
||||
"root_cause_category": "database",
|
||||
"validity_score": 0.45,
|
||||
"investigation_loop_count": 6,
|
||||
"user_id": "u-1",
|
||||
"user_email": "u@example.com",
|
||||
"org_id": "org-1",
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
def test_record_miss_returns_none_and_warns_on_write_failure(
|
||||
opensre_home: Path, monkeypatch, capsys
|
||||
) -> None:
|
||||
def _explode(*_args, **_kwargs):
|
||||
raise PermissionError("disk full")
|
||||
|
||||
monkeypatch.setattr("pathlib.Path.open", _explode)
|
||||
|
||||
result = record_miss(_feedback_dict(), taxonomy=MissTaxonomy.RETRIEVAL_GAP)
|
||||
|
||||
assert result is None
|
||||
err = capsys.readouterr().err
|
||||
assert "could not record miss" in err
|
||||
assert "disk full" in err
|
||||
|
||||
|
||||
def test_record_miss_persists_to_jsonl(opensre_home: Path) -> None:
|
||||
feedback = _feedback_dict()
|
||||
final_state = {"pipeline_name": "checkout/api", "severity": "critical"}
|
||||
|
||||
rec = record_miss(
|
||||
feedback,
|
||||
taxonomy=MissTaxonomy.RETRIEVAL_GAP,
|
||||
final_state=final_state,
|
||||
)
|
||||
|
||||
assert rec is not None
|
||||
assert rec["taxonomy"] == "retrieval_gap"
|
||||
assert rec["pipeline_name"] == "checkout/api"
|
||||
assert rec["severity"] == "critical"
|
||||
assert rec["feedback_id"] == "fb-001"
|
||||
|
||||
path = misses_path()
|
||||
assert path.exists()
|
||||
written = [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
||||
assert len(written) == 1
|
||||
assert written[0]["miss_id"] == rec["miss_id"]
|
||||
|
||||
|
||||
def test_record_miss_accepts_string_taxonomy(opensre_home: Path) -> None:
|
||||
rec = record_miss(_feedback_dict(), taxonomy="reasoning_gap")
|
||||
assert rec is not None
|
||||
assert rec["taxonomy"] == "reasoning_gap"
|
||||
|
||||
|
||||
def test_load_misses_skips_corrupt_lines(opensre_home: Path) -> None:
|
||||
path = misses_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
valid = {
|
||||
"miss_id": "ok",
|
||||
"alert_name": "a",
|
||||
"taxonomy": "tool_failure",
|
||||
"timestamp": "2026-06-02T00:00:00+00:00",
|
||||
}
|
||||
path.write_text(
|
||||
"\n".join(
|
||||
[
|
||||
json.dumps(valid),
|
||||
"not json at all",
|
||||
"",
|
||||
'{"miss_id": "partial"', # malformed
|
||||
]
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
rows = load_misses()
|
||||
assert [r["miss_id"] for r in rows] == ["ok"]
|
||||
|
||||
|
||||
def test_load_misses_filters_by_since_and_taxonomy(opensre_home: Path) -> None:
|
||||
now = datetime.now(UTC)
|
||||
record_miss(
|
||||
_feedback_dict(timestamp=(now - timedelta(days=10)).isoformat(), alert_name="old"),
|
||||
taxonomy=MissTaxonomy.RETRIEVAL_GAP,
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(
|
||||
timestamp=(now - timedelta(hours=2)).isoformat(), alert_name="fresh-retrieval"
|
||||
),
|
||||
taxonomy=MissTaxonomy.RETRIEVAL_GAP,
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(timestamp=(now - timedelta(hours=1)).isoformat(), alert_name="fresh-tool"),
|
||||
taxonomy=MissTaxonomy.TOOL_FAILURE,
|
||||
)
|
||||
|
||||
recent = load_misses(since=now - timedelta(days=1))
|
||||
assert {r["alert_name"] for r in recent} == {"fresh-retrieval", "fresh-tool"}
|
||||
|
||||
retrieval = load_misses(taxonomy=MissTaxonomy.RETRIEVAL_GAP)
|
||||
assert {r["alert_name"] for r in retrieval} == {"old", "fresh-retrieval"}
|
||||
|
||||
|
||||
# ── recurrence + stats ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_compute_recurrence_groups_by_alert_and_taxonomy(opensre_home: Path) -> None:
|
||||
record_miss(_feedback_dict(alert_name="A"), taxonomy=MissTaxonomy.RETRIEVAL_GAP)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="A", feedback_id="fb-002"), taxonomy=MissTaxonomy.RETRIEVAL_GAP
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="A", feedback_id="fb-003"), taxonomy=MissTaxonomy.REASONING_GAP
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="B", feedback_id="fb-004"), taxonomy=MissTaxonomy.TOOL_FAILURE
|
||||
)
|
||||
|
||||
rec = compute_recurrence(load_misses())
|
||||
assert rec[("A", "retrieval_gap")] == 2
|
||||
assert rec[("A", "reasoning_gap")] == 1
|
||||
assert rec[("B", "tool_failure")] == 1
|
||||
|
||||
|
||||
def test_compute_stats_reports_totals_and_recurring(opensre_home: Path) -> None:
|
||||
record_miss(_feedback_dict(alert_name="A"), taxonomy=MissTaxonomy.RETRIEVAL_GAP)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="A", feedback_id="fb-002"), taxonomy=MissTaxonomy.RETRIEVAL_GAP
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="B", feedback_id="fb-003"), taxonomy=MissTaxonomy.TOOL_FAILURE
|
||||
)
|
||||
|
||||
stats = compute_stats(load_misses())
|
||||
assert stats["total"] == 3
|
||||
assert stats["by_taxonomy"]["retrieval_gap"] == 2
|
||||
assert stats["by_taxonomy"]["tool_failure"] == 1
|
||||
assert stats["unique_alerts"] == 2
|
||||
assert stats["recurring"] == [("A", "retrieval_gap", 2)]
|
||||
|
||||
|
||||
# ── filter_top_misses prioritises recurring pairs ─────────────────────────────
|
||||
|
||||
|
||||
def test_filter_top_misses_dedupes_and_prefers_recurrence(opensre_home: Path) -> None:
|
||||
now = datetime.now(UTC)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="A", timestamp=(now - timedelta(days=3)).isoformat()),
|
||||
taxonomy=MissTaxonomy.RETRIEVAL_GAP,
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(
|
||||
alert_name="A", feedback_id="fb-002", timestamp=(now - timedelta(days=1)).isoformat()
|
||||
),
|
||||
taxonomy=MissTaxonomy.RETRIEVAL_GAP,
|
||||
)
|
||||
record_miss(
|
||||
_feedback_dict(alert_name="B", feedback_id="fb-003", timestamp=now.isoformat()),
|
||||
taxonomy=MissTaxonomy.TOOL_FAILURE,
|
||||
)
|
||||
|
||||
top = filter_top_misses(load_misses(), top=10)
|
||||
|
||||
pairs = [(r["alert_name"], r["taxonomy"]) for r in top]
|
||||
# Deduped to one per (alert, taxonomy)
|
||||
assert pairs == [("A", "retrieval_gap"), ("B", "tool_failure")]
|
||||
|
||||
|
||||
def test_filter_top_misses_handles_zero_and_empty() -> None:
|
||||
assert filter_top_misses([], top=5) == []
|
||||
assert filter_top_misses([{"alert_name": "x"}], top=0) == []
|
||||
|
||||
|
||||
# ── scenario conversion ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_to_benchmark_scenario_carries_rubric() -> None:
|
||||
miss = MissRecord(
|
||||
miss_id="m-1",
|
||||
run_id="r-1",
|
||||
alert_name="checkout-api 5xx",
|
||||
pipeline_name="checkout/api",
|
||||
severity="critical",
|
||||
timestamp="2026-06-02T10:00:00+00:00",
|
||||
taxonomy="retrieval_gap",
|
||||
taxonomy_detail="missed the canary deploy",
|
||||
root_cause="Bad deploy at 09:42",
|
||||
root_cause_category="deploy",
|
||||
)
|
||||
scenario = to_benchmark_scenario(miss)
|
||||
|
||||
assert scenario["alert_name"] == "checkout-api 5xx"
|
||||
assert scenario["title"].startswith("[Regression]")
|
||||
assert scenario["pipeline_name"] == "checkout/api"
|
||||
assert scenario["severity"] == "critical"
|
||||
assert scenario["_meta"]["miss_id"] == "m-1"
|
||||
assert scenario["_meta"]["taxonomy"] == "retrieval_gap"
|
||||
|
||||
# The rubric MUST live at commonAnnotations.scoring_points — that is
|
||||
# where extract_scoring_points reads it and where
|
||||
# strip_scoring_points_from_alert removes it before the agent sees the
|
||||
# alert. Anywhere else and either the judge misses the rubric or the
|
||||
# agent gets handed the answer.
|
||||
rubric = scenario["commonAnnotations"]["scoring_points"]
|
||||
assert rubric["expected_root_cause"] == "Bad deploy at 09:42"
|
||||
assert rubric["expected_category"] == "deploy"
|
||||
assert rubric["miss_notes"] == "missed the canary deploy"
|
||||
assert "scoring_points" not in scenario["_meta"]
|
||||
|
||||
|
||||
def test_to_benchmark_scenario_is_strippable_for_blind_agent_runs() -> None:
|
||||
"""Confirm the produced scenario is compatible with the existing
|
||||
strip_scoring_points_from_alert helper — i.e. the rubric ends up where
|
||||
the helper expects and is actually removed for non-evaluate runs."""
|
||||
from integrations.opensre import (
|
||||
extract_scoring_points,
|
||||
strip_scoring_points_from_alert,
|
||||
)
|
||||
|
||||
miss = MissRecord(
|
||||
miss_id="m-1",
|
||||
alert_name="checkout-api 5xx",
|
||||
taxonomy="retrieval_gap",
|
||||
taxonomy_detail="missed the canary deploy",
|
||||
root_cause="Bad deploy at 09:42",
|
||||
root_cause_category="deploy",
|
||||
)
|
||||
scenario = to_benchmark_scenario(miss)
|
||||
|
||||
# Judge can read the rubric.
|
||||
rubric_text = extract_scoring_points(scenario)
|
||||
assert "Bad deploy at 09:42" in rubric_text
|
||||
|
||||
# Strip removes it cleanly — agent does not see the answer.
|
||||
blind = strip_scoring_points_from_alert(scenario)
|
||||
assert "scoring_points" not in blind["commonAnnotations"]
|
||||
assert extract_scoring_points(blind) == ""
|
||||
|
||||
|
||||
def test_export_scenarios_handles_json_null_alert_name_and_taxonomy(tmp_path: Path) -> None:
|
||||
"""A JSON null in misses.jsonl returns Python None from dict.get — the
|
||||
export path must not crash _slugify's re.sub on those values."""
|
||||
misses: list[MissRecord] = [
|
||||
MissRecord(
|
||||
miss_id="m-1",
|
||||
alert_name=None, # type: ignore[typeddict-item]
|
||||
taxonomy=None, # type: ignore[typeddict-item]
|
||||
timestamp="2026-06-02T10:00:00+00:00",
|
||||
root_cause="rc",
|
||||
),
|
||||
]
|
||||
out = tmp_path / "scenarios"
|
||||
|
||||
written = export_scenarios(misses, out)
|
||||
|
||||
assert len(written) == 1
|
||||
# Slug falls back to the index + "unknown" taxonomy rather than crashing.
|
||||
assert written[0].parent.name == "0001_miss-0001_unknown"
|
||||
|
||||
|
||||
def test_export_scenarios_writes_per_case_directories(tmp_path: Path) -> None:
|
||||
misses: list[MissRecord] = [
|
||||
MissRecord(
|
||||
miss_id="m-1",
|
||||
alert_name="checkout 5xx",
|
||||
taxonomy="retrieval_gap",
|
||||
timestamp="2026-06-02T10:00:00+00:00",
|
||||
root_cause="rc",
|
||||
),
|
||||
MissRecord(
|
||||
miss_id="m-2",
|
||||
alert_name="checkout 5xx",
|
||||
taxonomy="reasoning_gap",
|
||||
timestamp="2026-06-02T11:00:00+00:00",
|
||||
root_cause="rc2",
|
||||
),
|
||||
]
|
||||
out = tmp_path / "scenarios"
|
||||
|
||||
written = export_scenarios(misses, out)
|
||||
|
||||
assert len(written) == 2
|
||||
assert all(p.name == "alert.json" for p in written)
|
||||
parents = [p.parent.name for p in written]
|
||||
assert any("retrieval_gap" in name for name in parents)
|
||||
assert any("reasoning_gap" in name for name in parents)
|
||||
|
||||
payload = json.loads(written[0].read_text())
|
||||
assert payload["_meta"]["taxonomy"] in {"retrieval_gap", "reasoning_gap"}
|
||||
|
||||
|
||||
# ── parse_since input parsing ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_since_accepts_relative_units() -> None:
|
||||
now = datetime.now(UTC)
|
||||
one_day_ago = parse_since("1d")
|
||||
assert (now - one_day_ago) < timedelta(seconds=5) + timedelta(days=1)
|
||||
assert (now - one_day_ago) > timedelta(hours=23, minutes=55)
|
||||
|
||||
assert parse_since("12h") < now
|
||||
assert parse_since("2w") < now - timedelta(days=13)
|
||||
|
||||
|
||||
def test_parse_since_accepts_iso_timestamp() -> None:
|
||||
parsed = parse_since("2026-06-02T10:00:00+00:00")
|
||||
assert parsed == datetime(2026, 6, 2, 10, 0, tzinfo=UTC)
|
||||
|
||||
|
||||
def test_parse_since_rejects_garbage() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
parse_since("")
|
||||
with pytest.raises(ValueError):
|
||||
parse_since("forever ago")
|
||||
@@ -0,0 +1,36 @@
|
||||
"""ShellOutputSink.render_error appends ``/model`` and ``/auth login`` hints on a credit-exhausted error."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.llm.shared.llm_retry import CREDIT_EXHAUSTED_MARKER
|
||||
from surfaces.interactive_shell.runtime.agent_harness_adapters import ShellOutputSink
|
||||
|
||||
|
||||
class _RecordingConsole:
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
|
||||
def print(self, message: str = "") -> None:
|
||||
self.lines.append(str(message))
|
||||
|
||||
|
||||
def _render_error(message: str) -> str:
|
||||
console = _RecordingConsole()
|
||||
ShellOutputSink(console).render_error(message) # type: ignore[arg-type]
|
||||
return "\n".join(console.lines)
|
||||
|
||||
|
||||
def test_render_error_shows_model_hint_on_credit_exhaustion() -> None:
|
||||
output = _render_error(f"Anthropic {CREDIT_EXHAUSTED_MARKER}. Original error: 400")
|
||||
assert "/model" in output
|
||||
|
||||
|
||||
def test_render_error_shows_auth_login_hint_on_credit_exhaustion() -> None:
|
||||
output = _render_error(f"Anthropic {CREDIT_EXHAUSTED_MARKER}. Original error: 400")
|
||||
assert "/auth login" in output
|
||||
|
||||
|
||||
def test_render_error_no_hint_for_generic_error() -> None:
|
||||
output = _render_error("some other failure")
|
||||
assert "/model" not in output
|
||||
assert "/auth login" not in output
|
||||
@@ -0,0 +1,51 @@
|
||||
"""The turn-error render adds ``/model`` and ``/auth login`` recovery hints on a credit-exhausted error."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.llm.shared.llm_retry import LLMCreditExhaustedError
|
||||
from surfaces.interactive_shell.runtime.agent_presentation import (
|
||||
AgentEvent,
|
||||
AgentPresentationState,
|
||||
_render_agent_presentation_transition,
|
||||
)
|
||||
|
||||
|
||||
class _RecordingConsole:
|
||||
def __init__(self) -> None:
|
||||
self.lines: list[str] = []
|
||||
|
||||
def print(self, text: str = "") -> None:
|
||||
self.lines.append(text)
|
||||
|
||||
|
||||
def _render_turn_error(error: Exception) -> str:
|
||||
console = _RecordingConsole()
|
||||
asyncio.run(
|
||||
_render_agent_presentation_transition(
|
||||
previous=AgentPresentationState(),
|
||||
current=AgentPresentationState(),
|
||||
event=AgentEvent(type="turn_error", error=error),
|
||||
console=console, # type: ignore[arg-type]
|
||||
spinner=MagicMock(),
|
||||
)
|
||||
)
|
||||
return "\n".join(console.lines)
|
||||
|
||||
|
||||
def test_credit_exhausted_turn_error_shows_model_hint() -> None:
|
||||
output = _render_turn_error(LLMCreditExhaustedError("Anthropic credit exhausted"))
|
||||
assert "/model" in output
|
||||
|
||||
|
||||
def test_credit_exhausted_turn_error_shows_auth_login_hint() -> None:
|
||||
output = _render_turn_error(LLMCreditExhaustedError("Anthropic credit exhausted"))
|
||||
assert "/auth login" in output
|
||||
|
||||
|
||||
def test_other_turn_error_has_no_model_hint() -> None:
|
||||
output = _render_turn_error(RuntimeError("something else broke"))
|
||||
assert "/model" not in output
|
||||
assert "/auth login" not in output
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from surfaces.interactive_shell.runtime.background.notifications import (
|
||||
deliver_background_notifications,
|
||||
)
|
||||
from surfaces.interactive_shell.session.background_investigations import (
|
||||
BackgroundInvestigationRecord,
|
||||
)
|
||||
|
||||
|
||||
def test_deliver_background_notifications_sends_email_when_smtp_is_configured(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"integrations.catalog.resolve_effective_integrations",
|
||||
lambda: {
|
||||
"smtp": {
|
||||
"source": "local env",
|
||||
"config": {
|
||||
"host": "smtp.example.com",
|
||||
"port": 587,
|
||||
"security": "starttls",
|
||||
"from_address": "opensre@example.com",
|
||||
"default_to": "team@example.com",
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _fake_send_smtp_report(
|
||||
*, report: str, subject: str, smtp_ctx: dict[str, object]
|
||||
) -> tuple[bool, str]:
|
||||
captured["report"] = report
|
||||
captured["subject"] = subject
|
||||
captured["smtp_ctx"] = smtp_ctx
|
||||
return True, ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"integrations.smtp.delivery.send_smtp_report",
|
||||
_fake_send_smtp_report,
|
||||
)
|
||||
|
||||
record = BackgroundInvestigationRecord(
|
||||
task_id="bg-123",
|
||||
status="completed",
|
||||
command="/investigate checkout-latency",
|
||||
root_cause="postgres connection pool saturation",
|
||||
top_analysis=("rds cpu spike",),
|
||||
next_steps=("raise pool size",),
|
||||
stats={"tool_call_count": 4, "investigation_loop_count": 2, "validity_score": 0.8},
|
||||
)
|
||||
|
||||
results = deliver_background_notifications(record=record, channels=("email",))
|
||||
|
||||
assert results == {"email": "sent"}
|
||||
assert captured["subject"] == "OpenSRE RCA complete: bg-123"
|
||||
assert "Root cause" in str(captured["report"])
|
||||
|
||||
|
||||
def test_deliver_background_notifications_skips_when_no_channels_configured() -> None:
|
||||
record = BackgroundInvestigationRecord(
|
||||
task_id="bg-123", status="completed", command="free-text"
|
||||
)
|
||||
results = deliver_background_notifications(record=record, channels=())
|
||||
assert results == {}
|
||||
|
||||
|
||||
def test_deliver_background_notifications_marks_missing_smtp(monkeypatch) -> None:
|
||||
monkeypatch.setattr("integrations.catalog.resolve_effective_integrations", lambda: {})
|
||||
record = BackgroundInvestigationRecord(
|
||||
task_id="bg-123", status="completed", command="free-text"
|
||||
)
|
||||
results = deliver_background_notifications(record=record, channels=("email",))
|
||||
assert results == {"email": "missing smtp integration"}
|
||||
@@ -0,0 +1,71 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.background.runner import (
|
||||
drain_background_notices,
|
||||
start_background_template_investigation,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
def test_enqueue_and_drain_background_notices() -> None:
|
||||
import io
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
session = Session()
|
||||
session.terminal.enqueue_background_notice("[bold]done[/bold]")
|
||||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
|
||||
drain_background_notices(session, console)
|
||||
|
||||
assert session.terminal.drain_background_notices() == []
|
||||
assert "done" in console.file.getvalue()
|
||||
|
||||
|
||||
def test_start_background_template_investigation_assigns_fresh_investigation_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import io
|
||||
|
||||
session = Session()
|
||||
session.last_investigation_id = "inv-stale"
|
||||
console = Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
|
||||
def _fake_run(
|
||||
*,
|
||||
cancel_requested,
|
||||
template_name: str,
|
||||
context_overrides,
|
||||
) -> dict[str, object]:
|
||||
_ = (cancel_requested, template_name, context_overrides)
|
||||
return {"root_cause": "done"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.runtime.investigation_adapter.run_sample_alert_for_session_background",
|
||||
_fake_run,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.runtime.background.runner.track_investigation",
|
||||
lambda **_kwargs: MagicMock(
|
||||
__enter__=MagicMock(return_value=MagicMock()),
|
||||
__exit__=MagicMock(return_value=False),
|
||||
),
|
||||
)
|
||||
|
||||
task_id = start_background_template_investigation(
|
||||
template_name="generic",
|
||||
session=session,
|
||||
console=console,
|
||||
display_command="/investigate generic",
|
||||
investigation_target="generic",
|
||||
)
|
||||
|
||||
assert session.last_investigation_id
|
||||
assert session.last_investigation_id != "inv-stale"
|
||||
record = session.terminal.background_investigations[task_id]
|
||||
assert record.investigation_id == session.last_investigation_id
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Tests for validated interactive-shell runtime context assembly."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.application import create_app_session
|
||||
from prompt_toolkit.history import InMemoryHistory
|
||||
from prompt_toolkit.input import DummyInput
|
||||
from prompt_toolkit.output import DummyOutput
|
||||
from pydantic import ValidationError
|
||||
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
from surfaces.interactive_shell.controller import InteractiveShellController
|
||||
from surfaces.interactive_shell.runtime.context import (
|
||||
ReplRuntimeContext,
|
||||
SessionBootstrapSpec,
|
||||
create_repl_runtime_context,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.core.state import (
|
||||
ReplState,
|
||||
SpinnerState,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
def _prompt_session() -> PromptSession[str]:
|
||||
return PromptSession(history=InMemoryHistory())
|
||||
|
||||
|
||||
def test_create_context_applies_canonical_session_bootstrap(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
registry = TaskRegistry()
|
||||
hydrate_calls: list[str] = []
|
||||
|
||||
def _hydrate(self: Session) -> None:
|
||||
hydrate_calls.append(self.session_id)
|
||||
self.configured_integrations = ("github",)
|
||||
self.configured_integrations_known = True
|
||||
|
||||
monkeypatch.setattr(Session, "hydrate_configured_integrations", _hydrate)
|
||||
monkeypatch.setattr(TaskRegistry, "persistent", staticmethod(lambda: registry))
|
||||
|
||||
with create_app_session(input=DummyInput(), output=DummyOutput()):
|
||||
prompt = _prompt_session()
|
||||
session = Session()
|
||||
context = create_repl_runtime_context(
|
||||
session=session,
|
||||
pt_session=prompt,
|
||||
active_theme_name="pink",
|
||||
)
|
||||
|
||||
assert context.session is session
|
||||
assert session.terminal.active_theme_name == "pink"
|
||||
assert session.configured_integrations == ("github",)
|
||||
assert session.configured_integrations_known is True
|
||||
assert hydrate_calls == [session.session_id]
|
||||
assert session.task_registry is registry
|
||||
assert session.terminal.prompt_history_backend is prompt.history
|
||||
|
||||
|
||||
def test_context_supports_lightweight_bootstrap_for_unit_seams(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
Session,
|
||||
"hydrate_configured_integrations",
|
||||
lambda _self: (_ for _ in ()).throw(AssertionError("hydrated")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
TaskRegistry,
|
||||
"persistent",
|
||||
staticmethod(lambda: (_ for _ in ()).throw(AssertionError("persistent"))),
|
||||
)
|
||||
|
||||
context = create_repl_runtime_context(
|
||||
active_theme_name="green",
|
||||
hydrate_integrations=False,
|
||||
persistent_tasks=False,
|
||||
)
|
||||
|
||||
assert context.session.terminal.active_theme_name == "green"
|
||||
assert isinstance(context.state, ReplState)
|
||||
assert isinstance(context.spinner, SpinnerState)
|
||||
|
||||
|
||||
def test_create_context_registers_jsonl_session_trace_sink(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""REPL boot wires the JSONL session-trace sink for ATM / span metrics."""
|
||||
from platform.observability.trace.spans import (
|
||||
NoopSessionTraceSink,
|
||||
get_session_trace_sink,
|
||||
is_session_trace_active,
|
||||
set_session_trace_sink,
|
||||
)
|
||||
from surfaces.interactive_shell.session.trace_sink import JsonlSessionTraceSink
|
||||
|
||||
monkeypatch.setattr(
|
||||
Session,
|
||||
"hydrate_configured_integrations",
|
||||
lambda _self: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
TaskRegistry,
|
||||
"persistent",
|
||||
staticmethod(TaskRegistry),
|
||||
)
|
||||
set_session_trace_sink(NoopSessionTraceSink())
|
||||
try:
|
||||
create_repl_runtime_context(
|
||||
hydrate_integrations=False,
|
||||
persistent_tasks=False,
|
||||
)
|
||||
assert is_session_trace_active()
|
||||
assert isinstance(get_session_trace_sink(), JsonlSessionTraceSink)
|
||||
finally:
|
||||
set_session_trace_sink(NoopSessionTraceSink())
|
||||
|
||||
|
||||
def test_create_context_uses_noop_trace_sink_for_in_memory_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from core.agent_harness.session import InMemorySessionStorage
|
||||
from platform.observability.trace.spans import (
|
||||
NoopSessionTraceSink,
|
||||
get_session_trace_sink,
|
||||
is_session_trace_active,
|
||||
set_session_trace_sink,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
Session,
|
||||
"hydrate_configured_integrations",
|
||||
lambda _self: None,
|
||||
)
|
||||
set_session_trace_sink(NoopSessionTraceSink())
|
||||
try:
|
||||
create_repl_runtime_context(
|
||||
session=Session(storage=InMemorySessionStorage()),
|
||||
hydrate_integrations=False,
|
||||
persistent_tasks=False,
|
||||
)
|
||||
assert not is_session_trace_active()
|
||||
assert isinstance(get_session_trace_sink(), NoopSessionTraceSink)
|
||||
finally:
|
||||
set_session_trace_sink(NoopSessionTraceSink())
|
||||
|
||||
|
||||
def test_context_uses_canonical_initial_mutable_state() -> None:
|
||||
context = ReplRuntimeContext(session=Session())
|
||||
|
||||
assert isinstance(context.state, ReplState)
|
||||
assert isinstance(context.spinner, SpinnerState)
|
||||
assert context.state.exit_requested is False
|
||||
assert context.spinner.streaming is False
|
||||
|
||||
|
||||
def test_context_preserves_explicit_partial_mutable_state() -> None:
|
||||
state = ReplState()
|
||||
context = ReplRuntimeContext(session=Session(), state=state)
|
||||
|
||||
assert context.state is state
|
||||
assert isinstance(context.spinner, SpinnerState)
|
||||
|
||||
|
||||
def test_context_rejects_invalid_state_contracts() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
ReplRuntimeContext(session=object()) # type: ignore[arg-type]
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
SessionBootstrapSpec(active_theme_name=" ")
|
||||
|
||||
|
||||
def test_context_assignment_validates_inbox_type() -> None:
|
||||
context = ReplRuntimeContext(session=Session())
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
context.inbox = object() # type: ignore[assignment]
|
||||
|
||||
|
||||
def test_controller_reuses_validated_runtime_context() -> None:
|
||||
session = Session()
|
||||
state = ReplState()
|
||||
spinner = SpinnerState()
|
||||
context = ReplRuntimeContext(session=session, state=state, spinner=spinner)
|
||||
|
||||
controller = InteractiveShellController(context)
|
||||
|
||||
assert controller.runtime_context is context
|
||||
assert controller.session is session
|
||||
assert controller.state is state
|
||||
assert controller.spinner is spinner
|
||||
assert controller.input_reader.state is state
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Pure input-action decisions for the interactive shell controller."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from surfaces.interactive_shell.runtime.input import (
|
||||
InputCancelled,
|
||||
InputClosed,
|
||||
InputEvent,
|
||||
InputSubmitted,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input.actions import (
|
||||
QUEUE_DURING_CONFIRMATION_WARNING,
|
||||
CancelTurn,
|
||||
CloseShell,
|
||||
DeliverConfirmation,
|
||||
IgnoreInput,
|
||||
ShellInputSnapshot,
|
||||
SubmitTurn,
|
||||
decide_input_action,
|
||||
)
|
||||
|
||||
|
||||
def _decide(
|
||||
event: InputEvent,
|
||||
*,
|
||||
exit_requested: bool = False,
|
||||
dispatch_running: bool = False,
|
||||
awaiting_confirmation: bool = False,
|
||||
needs_exclusive_stdin: bool = False,
|
||||
) -> object:
|
||||
return decide_input_action(
|
||||
event,
|
||||
ShellInputSnapshot(
|
||||
exit_requested=exit_requested,
|
||||
dispatch_running=dispatch_running,
|
||||
awaiting_confirmation=awaiting_confirmation,
|
||||
),
|
||||
needs_exclusive_stdin=lambda _text: needs_exclusive_stdin,
|
||||
)
|
||||
|
||||
|
||||
def test_decide_closes_on_input_closed() -> None:
|
||||
assert _decide(InputClosed()) == CloseShell()
|
||||
|
||||
|
||||
def test_decide_cancels_on_input_cancelled() -> None:
|
||||
assert _decide(InputCancelled()) == CancelTurn()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", ["", " "])
|
||||
def test_decide_ignores_empty_or_blank_submissions(text: str) -> None:
|
||||
assert _decide(InputSubmitted(text)) == IgnoreInput()
|
||||
|
||||
|
||||
def test_decide_ignores_submitted_input_after_exit_requested() -> None:
|
||||
assert _decide(InputSubmitted("/status"), exit_requested=True) == IgnoreInput()
|
||||
|
||||
|
||||
def test_decide_cancels_when_cancel_request_is_typed_during_dispatch() -> None:
|
||||
assert _decide(InputSubmitted(" /cancel "), dispatch_running=True) == CancelTurn(
|
||||
submitted_text="/cancel"
|
||||
)
|
||||
|
||||
|
||||
def test_decide_delivers_stripped_confirmation_answer() -> None:
|
||||
assert _decide(
|
||||
InputSubmitted(" yes "),
|
||||
awaiting_confirmation=True,
|
||||
) == DeliverConfirmation(text="yes")
|
||||
|
||||
|
||||
def test_decide_submits_non_confirmation_input_while_confirmation_is_pending() -> None:
|
||||
assert _decide(
|
||||
InputSubmitted("run /status"),
|
||||
awaiting_confirmation=True,
|
||||
) == SubmitTurn(
|
||||
text="run /status",
|
||||
warning=QUEUE_DURING_CONFIRMATION_WARNING,
|
||||
)
|
||||
|
||||
|
||||
def test_decide_submits_normal_turn_without_wait_by_default() -> None:
|
||||
assert _decide(InputSubmitted(" show status ")) == SubmitTurn(text="show status")
|
||||
|
||||
|
||||
def test_decide_submits_normal_turn_with_exclusive_stdin_wait() -> None:
|
||||
assert _decide(InputSubmitted("/integrations"), needs_exclusive_stdin=True) == SubmitTurn(
|
||||
text="/integrations",
|
||||
wait_until_idle=True,
|
||||
)
|
||||
@@ -0,0 +1,337 @@
|
||||
"""Tests for hydrating configured integrations onto the REPL session at boot.
|
||||
|
||||
Without this the agent cannot answer "is X installed?" and the integration
|
||||
guards stay dead because ``configured_integrations_known`` never flips to True.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
import surfaces.interactive_shell.main as main_entrypoint
|
||||
from core.agent_harness.session.integration_resolution import IntegrationResolutionResult
|
||||
from surfaces.interactive_shell.runtime.startup import first_launch_github as flg
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
def _console() -> Console:
|
||||
return Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
|
||||
|
||||
def test_hydrate_populates_session_from_effective_resolution(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(
|
||||
"platform.harness_ports.configured_integration_services",
|
||||
lambda: ["gitlab", "datadog"],
|
||||
)
|
||||
session = Session()
|
||||
session.hydrate_configured_integrations()
|
||||
assert session.configured_integrations_known is True
|
||||
# Metadata discovery covers env + local store and is returned in sorted order.
|
||||
assert session.configured_integrations == ("datadog", "gitlab")
|
||||
|
||||
|
||||
def test_hydrate_marks_known_even_when_none_configured(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(
|
||||
"platform.harness_ports.configured_integration_services",
|
||||
list,
|
||||
)
|
||||
session = Session()
|
||||
session.hydrate_configured_integrations()
|
||||
assert session.configured_integrations_known is True
|
||||
assert session.configured_integrations == ()
|
||||
|
||||
|
||||
def test_warm_resolved_integrations_populates_cache(monkeypatch: Any) -> None:
|
||||
resolved = {"datadog": {"site": "datadoghq.com"}, "grafana": {"url": "http://localhost"}}
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
lambda: resolved,
|
||||
)
|
||||
session = Session()
|
||||
session.warm_resolved_integrations()
|
||||
assert session.resolved_integrations_cache == resolved
|
||||
|
||||
|
||||
def test_warm_resolved_integrations_is_idempotent(monkeypatch: Any) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def _resolve() -> dict[str, Any]:
|
||||
calls.append("resolve")
|
||||
return {"github": {}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
_resolve,
|
||||
)
|
||||
session = Session()
|
||||
session.warm_resolved_integrations()
|
||||
session.warm_resolved_integrations()
|
||||
assert calls == ["resolve"]
|
||||
|
||||
|
||||
def test_warm_resolved_integrations_skips_empty_cache(monkeypatch: Any) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def _resolve() -> dict[str, Any]:
|
||||
calls.append("resolve")
|
||||
return {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
_resolve,
|
||||
)
|
||||
session = Session()
|
||||
session.warm_resolved_integrations()
|
||||
assert session.resolved_integrations_cache is None
|
||||
session.warm_resolved_integrations()
|
||||
assert calls == ["resolve", "resolve"]
|
||||
|
||||
|
||||
def test_warm_resolved_integrations_uses_quiet_resolve(monkeypatch: Any) -> None:
|
||||
progress_calls: list[str] = []
|
||||
quiet_calls: list[str] = []
|
||||
|
||||
monkeypatch.setattr(
|
||||
"tools.investigation.stages.resolve_integrations.resolve_integrations",
|
||||
lambda _state: progress_calls.append("progress") or {"resolved_integrations": {}},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
lambda: quiet_calls.append("quiet") or {"datadog": {}},
|
||||
)
|
||||
|
||||
session = Session()
|
||||
session.warm_resolved_integrations()
|
||||
|
||||
assert quiet_calls == ["quiet"]
|
||||
assert progress_calls == []
|
||||
assert session.resolved_integrations_cache == {"datadog": {}}
|
||||
|
||||
|
||||
def test_get_integrations_returns_pydantic_cached_result(monkeypatch: Any) -> None:
|
||||
def _unexpected_resolve() -> dict[str, Any]:
|
||||
raise AssertionError("cached integrations should not re-resolve")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
_unexpected_resolve,
|
||||
)
|
||||
session = Session()
|
||||
session.resolved_integrations_cache = {"datadog": {"site": "datadoghq.com"}}
|
||||
|
||||
result = session.get_integrations()
|
||||
|
||||
assert isinstance(result, IntegrationResolutionResult)
|
||||
assert result.resolved_integrations == {"datadog": {"site": "datadoghq.com"}}
|
||||
assert result.services == ("datadog",)
|
||||
|
||||
|
||||
def test_get_integrations_respects_explicit_empty_cache(monkeypatch: Any) -> None:
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
lambda: calls.append("resolve") or {"datadog": {}},
|
||||
)
|
||||
session = Session()
|
||||
session.resolved_integrations_cache = {}
|
||||
|
||||
result = session.get_integrations()
|
||||
|
||||
assert result.resolved_integrations == {}
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_get_integrations_warms_metadata_only_cache(monkeypatch: Any) -> None:
|
||||
calls: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
lambda: calls.append("resolve") or {"datadog": {"site": "datadoghq.com"}},
|
||||
)
|
||||
session = Session()
|
||||
session.resolved_integrations_cache = {"_gateway_chat_id": "chat-1"}
|
||||
|
||||
result = session.get_integrations()
|
||||
|
||||
assert calls == ["resolve"]
|
||||
assert result.resolved_integrations == {
|
||||
"_gateway_chat_id": "chat-1",
|
||||
"datadog": {"site": "datadoghq.com"},
|
||||
}
|
||||
|
||||
|
||||
def test_stale_background_warm_does_not_overwrite_refreshed_cache() -> None:
|
||||
session = Session()
|
||||
stale_generation = session.integrations._warm_generation
|
||||
session.integrations._warm_generation += 1
|
||||
session.integrations._store(
|
||||
{"fresh": {"token": "new"}}, generation=session.integrations._warm_generation
|
||||
)
|
||||
session.integrations._store({"stale": {"token": "old"}}, generation=stale_generation)
|
||||
assert session.resolved_integrations_cache == {"fresh": {"token": "new"}}
|
||||
|
||||
|
||||
def test_hydrate_entrypoint_does_not_warm_before_prompt(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(
|
||||
"platform.harness_ports.configured_integration_services",
|
||||
lambda: ["datadog"],
|
||||
)
|
||||
resolve_calls: list[str] = []
|
||||
|
||||
def _resolve() -> dict[str, Any]:
|
||||
resolve_calls.append("resolve")
|
||||
return {"datadog": {"site": "datadoghq.com"}}
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.agent_harness.session.integration_resolution.resolve_integrations",
|
||||
_resolve,
|
||||
)
|
||||
session = Session()
|
||||
session.hydrate_configured_integrations()
|
||||
assert session.configured_integrations_known is True
|
||||
assert session.resolved_integrations_cache is None
|
||||
assert resolve_calls == []
|
||||
|
||||
|
||||
def test_hydrate_leaves_unknown_on_failure(monkeypatch: Any) -> None:
|
||||
def _boom() -> list[str]:
|
||||
raise RuntimeError("catalog blew up")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"platform.harness_ports.configured_integration_services",
|
||||
_boom,
|
||||
)
|
||||
session = Session()
|
||||
session.hydrate_configured_integrations()
|
||||
assert session.configured_integrations_known is False
|
||||
assert session.configured_integrations == ()
|
||||
|
||||
|
||||
def test_gate_error_blocks_startup_without_bypass(monkeypatch: Any) -> None:
|
||||
"""On an unexpected gate error we must NOT fail open into the REPL unless an
|
||||
explicit bypass applies."""
|
||||
monkeypatch.setattr(
|
||||
flg,
|
||||
"should_require_github_login",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("gate broke")),
|
||||
)
|
||||
monkeypatch.setattr(flg, "_github_login_explicitly_bypassed", lambda: False)
|
||||
|
||||
assert flg.require_startup_github_login(_console()) is False
|
||||
|
||||
|
||||
def test_gate_error_allows_startup_with_bypass(monkeypatch: Any) -> None:
|
||||
monkeypatch.setattr(
|
||||
flg,
|
||||
"should_require_github_login",
|
||||
lambda: (_ for _ in ()).throw(RuntimeError("gate broke")),
|
||||
)
|
||||
monkeypatch.setattr(flg, "_github_login_explicitly_bypassed", lambda: True)
|
||||
|
||||
assert flg.require_startup_github_login(_console()) is True
|
||||
|
||||
|
||||
def test_run_repl_async_identifies_saved_github_username(monkeypatch: Any) -> None:
|
||||
identified: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
"platform.analytics.cli.identify_saved_github_username",
|
||||
lambda: identified.append("called"),
|
||||
)
|
||||
|
||||
def _run_initial_input(*_args: Any, **_kwargs: Any) -> int:
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(main_entrypoint, "run_initial_input", _run_initial_input)
|
||||
|
||||
class _Session:
|
||||
active_theme_name = None
|
||||
|
||||
def hydrate_configured_integrations(self) -> None:
|
||||
return None
|
||||
|
||||
def warm_resolved_integrations(self) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
main_entrypoint,
|
||||
"create_repl_runtime_context",
|
||||
lambda **_kwargs: SimpleNamespace(session=_Session(), inbox=None),
|
||||
)
|
||||
|
||||
class _PromptSession:
|
||||
history = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
main_entrypoint,
|
||||
"build_prompt_session",
|
||||
lambda: _PromptSession(),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main_entrypoint.run_repl_async(initial_input="hello"))
|
||||
|
||||
assert identified == ["called"]
|
||||
|
||||
|
||||
def test_run_repl_async_failed_resume_flushes_starter_session(
|
||||
monkeypatch: Any, tmp_path: Path
|
||||
) -> None:
|
||||
import asyncio
|
||||
|
||||
sessions_dir = tmp_path / "sessions"
|
||||
sessions_dir.mkdir()
|
||||
monkeypatch.setattr("config.constants.OPENSRE_HOME_DIR", tmp_path)
|
||||
monkeypatch.setattr(
|
||||
"platform.analytics.cli.identify_saved_github_username",
|
||||
lambda: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.command_registry.session_cmds.resume.resume_session_by_prefix",
|
||||
lambda *_args, **_kwargs: False,
|
||||
)
|
||||
|
||||
session = Session()
|
||||
flushed: list[str] = []
|
||||
original_flush = session.storage.flush
|
||||
|
||||
def _track_flush(current_session: Session) -> None:
|
||||
flushed.append(current_session.session_id)
|
||||
original_flush(current_session)
|
||||
|
||||
monkeypatch.setattr(session.storage, "flush", _track_flush)
|
||||
|
||||
class _PromptSession:
|
||||
history = None
|
||||
|
||||
monkeypatch.setattr(
|
||||
main_entrypoint,
|
||||
"build_prompt_session",
|
||||
lambda: _PromptSession(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
main_entrypoint,
|
||||
"create_repl_runtime_context",
|
||||
lambda **_kwargs: SimpleNamespace(session=session, inbox=None),
|
||||
)
|
||||
|
||||
exit_code = asyncio.run(main_entrypoint.run_repl_async(resume_session_id="missing-session"))
|
||||
|
||||
assert exit_code == 1
|
||||
assert flushed == [session.session_id]
|
||||
assert not (sessions_dir / f"{session.session_id}.jsonl").exists()
|
||||
|
||||
|
||||
def test_explicit_bypass_detects_skip_env(monkeypatch: Any) -> None:
|
||||
monkeypatch.setenv("OPENSRE_SKIP_GITHUB_LOGIN", "1")
|
||||
assert flg._github_login_explicitly_bypassed() is True
|
||||
|
||||
|
||||
def test_explicit_bypass_detects_ci_environment(monkeypatch: Any) -> None:
|
||||
monkeypatch.delenv("OPENSRE_SKIP_GITHUB_LOGIN", raising=False)
|
||||
monkeypatch.setenv("CI", "true")
|
||||
assert flg._github_login_explicitly_bypassed() is True
|
||||
@@ -0,0 +1,281 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from integrations.github import login as github_login_mod
|
||||
from integrations.github.login import GitHubLoginResult
|
||||
from integrations.github.mcp import DEFAULT_GITHUB_MCP_TOOLSETS, DEFAULT_GITHUB_MCP_URL
|
||||
from integrations.github.mcp_oauth import GitHubDeviceCode
|
||||
from platform.analytics import source as analytics_source
|
||||
from platform.terminal import theme as ui_theme
|
||||
from surfaces.interactive_shell.runtime.startup import first_launch_github as flg
|
||||
|
||||
|
||||
def _console() -> Console:
|
||||
return Console(file=io.StringIO(), force_terminal=False, highlight=False)
|
||||
|
||||
|
||||
def _terminal_console(output: io.StringIO) -> Console:
|
||||
return Console(file=output, force_terminal=True, color_system="truecolor", highlight=False)
|
||||
|
||||
|
||||
def _force_required(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Set every gate input so that login would be required."""
|
||||
monkeypatch.delenv("OPENSRE_SKIP_GITHUB_LOGIN", raising=False)
|
||||
monkeypatch.setattr(flg, "is_test_run", lambda: False)
|
||||
monkeypatch.setattr(flg, "repl_tty_interactive", lambda: True)
|
||||
monkeypatch.setattr(flg, "_github_already_configured", lambda: False)
|
||||
monkeypatch.setattr(flg, "read_github_login_deferred", lambda: False)
|
||||
|
||||
|
||||
def test_gate_required_when_all_conditions_met(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
assert flg.should_require_github_login() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "YES", "on"])
|
||||
def test_gate_skipped_by_env(monkeypatch: pytest.MonkeyPatch, value: str) -> None:
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setenv("OPENSRE_SKIP_GITHUB_LOGIN", value)
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_skipped_in_test_run(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(flg, "is_test_run", lambda: True)
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_required_on_linux_when_interactive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
assert flg.should_require_github_login() is True
|
||||
|
||||
|
||||
def test_gate_skipped_when_not_tty(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(flg, "repl_tty_interactive", lambda: False)
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_skipped_when_github_already_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(flg, "_github_already_configured", lambda: True)
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_required_when_github_not_configured(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Regression: GitHub config is authoritative. A prior completed login (no
|
||||
longer recorded via any standalone marker) must not let the REPL start once
|
||||
the GitHub integration has been removed."""
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(flg, "_github_already_configured", lambda: False)
|
||||
assert flg.should_require_github_login() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ci_env",
|
||||
[
|
||||
("CI", "true"),
|
||||
("GITHUB_ACTIONS", "true"),
|
||||
("OPENSRE_IS_TEST", "1"),
|
||||
],
|
||||
)
|
||||
def test_gate_skipped_in_ci_like_environment(
|
||||
monkeypatch: pytest.MonkeyPatch, ci_env: tuple[str, str]
|
||||
) -> None:
|
||||
monkeypatch.delenv("OPENSRE_SKIP_GITHUB_LOGIN", raising=False)
|
||||
monkeypatch.delenv("CI", raising=False)
|
||||
monkeypatch.delenv("GITHUB_ACTIONS", raising=False)
|
||||
monkeypatch.delenv("OPENSRE_IS_TEST", raising=False)
|
||||
monkeypatch.delenv("OPENSRE_INVESTIGATION_SOURCE", raising=False)
|
||||
monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False)
|
||||
monkeypatch.setattr(flg, "is_test_run", analytics_source.is_test_run)
|
||||
monkeypatch.setattr(flg, "repl_tty_interactive", lambda: True)
|
||||
monkeypatch.setattr(flg, "_github_already_configured", lambda: False)
|
||||
monkeypatch.setenv(ci_env[0], ci_env[1])
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_skipped_when_github_login_deferred(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(flg, "read_github_login_deferred", lambda: True)
|
||||
assert flg.should_require_github_login() is False
|
||||
|
||||
|
||||
def test_gate_required_when_stale_github_store_record_has_no_token(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Legacy installs may have an abandoned github store row without credentials."""
|
||||
_force_required(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
"integrations.store.get_integration",
|
||||
lambda service: (
|
||||
{
|
||||
"credentials": {
|
||||
"mode": "streamable-http",
|
||||
"url": DEFAULT_GITHUB_MCP_URL,
|
||||
"toolsets": list(DEFAULT_GITHUB_MCP_TOOLSETS),
|
||||
}
|
||||
}
|
||||
if service == "github"
|
||||
else None
|
||||
),
|
||||
)
|
||||
assert flg.should_require_github_login() is True
|
||||
|
||||
|
||||
def test_device_code_prompt_highlights_user_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("NO_COLOR", raising=False)
|
||||
monkeypatch.delenv("FORCE_COLOR", raising=False)
|
||||
ui_theme.set_active_theme("blue")
|
||||
output = io.StringIO()
|
||||
code = GitHubDeviceCode(
|
||||
device_code="dev-123",
|
||||
user_code="WXYZ-1234",
|
||||
verification_uri="https://github.com/login/device",
|
||||
expires_in=900,
|
||||
interval=5,
|
||||
)
|
||||
|
||||
flg._show_device_code(_terminal_console(output), code)
|
||||
|
||||
rendered = output.getvalue()
|
||||
assert f"{ui_theme.DEVICE_CODE_ANSI}WXYZ-1234" in rendered
|
||||
|
||||
|
||||
def test_orchestrator_success_proceeds_and_propagates(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "_offer_github_login", lambda _console: True)
|
||||
monkeypatch.setattr(
|
||||
github_login_mod,
|
||||
"authenticate_and_configure_github",
|
||||
lambda **_kwargs: GitHubLoginResult(ok=True, username="octocat", detail="OK"),
|
||||
)
|
||||
completed: list[str] = []
|
||||
monkeypatch.setattr(flg, "capture_github_login_completed", completed.append)
|
||||
cleared: list[bool] = []
|
||||
monkeypatch.setattr(flg, "clear_github_login_deferral", lambda: cleared.append(True))
|
||||
|
||||
proceed = flg.require_github_login_on_first_launch(_console())
|
||||
|
||||
assert proceed is True
|
||||
assert completed == ["octocat"]
|
||||
assert cleared == [True]
|
||||
|
||||
|
||||
def test_orchestrator_skip_at_menu_proceeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "_offer_github_login", lambda _console: False)
|
||||
deferred: list[bool] = []
|
||||
monkeypatch.setattr(flg, "write_github_login_deferred", deferred.append)
|
||||
|
||||
proceed = flg.require_github_login_on_first_launch(_console())
|
||||
|
||||
assert proceed is True
|
||||
assert deferred == [True]
|
||||
|
||||
|
||||
def test_orchestrator_escape_during_wait_proceeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "_offer_github_login", lambda _console: True)
|
||||
|
||||
def _raise_cancel(**_kwargs: object) -> GitHubLoginResult:
|
||||
raise KeyboardInterrupt
|
||||
|
||||
monkeypatch.setattr(github_login_mod, "authenticate_and_configure_github", _raise_cancel)
|
||||
deferred: list[bool] = []
|
||||
monkeypatch.setattr(flg, "write_github_login_deferred", deferred.append)
|
||||
|
||||
proceed = flg.require_github_login_on_first_launch(_console())
|
||||
|
||||
assert proceed is True
|
||||
assert deferred == [True]
|
||||
|
||||
|
||||
def test_orchestrator_failure_then_decline_retry_skips(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "_offer_github_login", lambda _console: True)
|
||||
monkeypatch.setattr(
|
||||
github_login_mod,
|
||||
"authenticate_and_configure_github",
|
||||
lambda **_kwargs: GitHubLoginResult(ok=False, detail="cannot verify"),
|
||||
)
|
||||
monkeypatch.setattr(flg, "_ask_retry", lambda _console: False)
|
||||
deferred: list[bool] = []
|
||||
monkeypatch.setattr(flg, "write_github_login_deferred", deferred.append)
|
||||
|
||||
proceed = flg.require_github_login_on_first_launch(_console())
|
||||
|
||||
assert proceed is True
|
||||
assert deferred == [True]
|
||||
|
||||
|
||||
def test_orchestrator_retries_until_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "_offer_github_login", lambda _console: True)
|
||||
calls = {"n": 0}
|
||||
|
||||
def _login(**_kwargs: object) -> GitHubLoginResult:
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
return GitHubLoginResult(ok=False, detail="cannot verify")
|
||||
return GitHubLoginResult(ok=True, username="octocat", detail="OK")
|
||||
|
||||
monkeypatch.setattr(github_login_mod, "authenticate_and_configure_github", _login)
|
||||
monkeypatch.setattr(flg, "_ask_retry", lambda _console: True)
|
||||
monkeypatch.setattr(flg, "capture_github_login_completed", lambda _username: None)
|
||||
monkeypatch.setattr(flg, "clear_github_login_deferral", lambda: None)
|
||||
|
||||
proceed = flg.require_github_login_on_first_launch(_console())
|
||||
|
||||
assert proceed is True
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_clear_github_login_deferral_noop_when_not_deferred(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(flg, "read_github_login_deferred", lambda: False)
|
||||
writes: list[bool] = []
|
||||
monkeypatch.setattr(flg, "write_github_login_deferred", writes.append)
|
||||
|
||||
flg.clear_github_login_deferral()
|
||||
|
||||
assert writes == []
|
||||
|
||||
|
||||
def test_clear_github_login_deferral_clears_when_deferred(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg, "read_github_login_deferred", lambda: True)
|
||||
writes: list[bool] = []
|
||||
monkeypatch.setattr(flg, "write_github_login_deferred", writes.append)
|
||||
|
||||
flg.clear_github_login_deferral()
|
||||
|
||||
assert writes == [False]
|
||||
|
||||
|
||||
def test_sleep_until_or_cancel_raises_on_escape(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(flg.os, "name", "posix")
|
||||
monkeypatch.setattr(flg.sys.stdin, "isatty", lambda: True)
|
||||
monkeypatch.setattr(flg.sys.stdin, "fileno", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
"surfaces.interactive_shell.ui.components.key_reader.read_key_unix",
|
||||
lambda **_kwargs: "cancel",
|
||||
)
|
||||
monkeypatch.setattr("termios.tcgetattr", lambda _fd: [0] * 7)
|
||||
monkeypatch.setattr("tty.setraw", lambda _fd: None)
|
||||
restored: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"termios.tcsetattr",
|
||||
lambda _fd, _when, _attrs: restored.append(True),
|
||||
)
|
||||
|
||||
def _ready(
|
||||
_fd: int, *_args: object, **_kwargs: object
|
||||
) -> tuple[list[int], list[int], list[int]]:
|
||||
return ([0], [], [])
|
||||
|
||||
monkeypatch.setattr("select.select", _ready)
|
||||
|
||||
with pytest.raises(KeyboardInterrupt):
|
||||
flg._sleep_until_or_cancel(1.0)
|
||||
|
||||
assert restored == [True]
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Tests for prompt input event conversion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import ReplState
|
||||
from surfaces.interactive_shell.runtime.input import (
|
||||
InputCancelled,
|
||||
InputClosed,
|
||||
InputSubmitted,
|
||||
PromptInputReader,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.input import prompt_input_reader as reader_module
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
class FakePrompt:
|
||||
def __init__(self, read: Callable[[], str]) -> None:
|
||||
self._read = read
|
||||
|
||||
async def read_prompt_text(self) -> str:
|
||||
return self._read()
|
||||
|
||||
|
||||
class SequencePrompt:
|
||||
def __init__(self, values: list[str]) -> None:
|
||||
self._values = values
|
||||
|
||||
async def read_prompt_text(self) -> str:
|
||||
return self._values.pop(0)
|
||||
|
||||
|
||||
def _reader(
|
||||
prompt: Any,
|
||||
state: ReplState | None = None,
|
||||
session: Session | None = None,
|
||||
console: Console | None = None,
|
||||
) -> PromptInputReader:
|
||||
return PromptInputReader(
|
||||
prompt,
|
||||
state or ReplState(),
|
||||
session or Session(),
|
||||
console or Console(file=io.StringIO(), force_terminal=False),
|
||||
)
|
||||
|
||||
|
||||
def _running_state() -> tuple[ReplState, asyncio.Task[None]]:
|
||||
state = ReplState()
|
||||
task = asyncio.create_task(asyncio.sleep(60))
|
||||
state.start_dispatch(task=task, cancel_event=threading.Event())
|
||||
return state, task
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_submits_normal_text(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
reset_calls = 0
|
||||
|
||||
def _reset() -> None:
|
||||
nonlocal reset_calls
|
||||
reset_calls += 1
|
||||
|
||||
monkeypatch.setattr(reader_module, "repl_reset_ctrl_c_gate", _reset)
|
||||
|
||||
event = await _reader(FakePrompt(lambda: "show incidents")).read()
|
||||
|
||||
assert event == InputSubmitted("show incidents")
|
||||
assert reset_calls == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_eof_with_dispatch_running_returns_cancelled() -> None:
|
||||
state, task = _running_state()
|
||||
try:
|
||||
event = await _reader(FakePrompt(lambda: (_ for _ in ()).throw(EOFError)), state).read()
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert event == InputCancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_eof_without_dispatch_renders_resume_hint() -> None:
|
||||
output = io.StringIO()
|
||||
session = Session()
|
||||
session.session_id = "session-123"
|
||||
console = Console(file=output, force_terminal=False, color_system=None)
|
||||
|
||||
event = await _reader(
|
||||
FakePrompt(lambda: (_ for _ in ()).throw(EOFError)),
|
||||
session=session,
|
||||
console=console,
|
||||
).read()
|
||||
|
||||
assert event == InputClosed()
|
||||
assert "Resume this session with:" in output.getvalue()
|
||||
assert "/resume session-123" in output.getvalue()
|
||||
assert "--resume session-123" in output.getvalue()
|
||||
assert "Goodbye!" in output.getvalue()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_keyboard_interrupt_with_dispatch_running_returns_cancelled() -> (
|
||||
None
|
||||
):
|
||||
state, task = _running_state()
|
||||
try:
|
||||
event = await _reader(
|
||||
FakePrompt(lambda: (_ for _ in ()).throw(KeyboardInterrupt)),
|
||||
state,
|
||||
).read()
|
||||
finally:
|
||||
task.cancel()
|
||||
await asyncio.gather(task, return_exceptions=True)
|
||||
|
||||
assert event == InputCancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_keyboard_interrupt_without_dispatch_uses_ctrl_c_gate(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(reader_module, "repl_prompt_note_ctrl_c", lambda _console, _sid: False)
|
||||
|
||||
event = await _reader(FakePrompt(lambda: (_ for _ in ()).throw(KeyboardInterrupt))).read()
|
||||
|
||||
assert event == InputCancelled()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_keyboard_interrupt_without_dispatch_can_close(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(reader_module, "repl_prompt_note_ctrl_c", lambda _console, _sid: True)
|
||||
|
||||
event = await _reader(FakePrompt(lambda: (_ for _ in ()).throw(KeyboardInterrupt))).read()
|
||||
|
||||
assert event == InputClosed()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_strips_cpr_sequences() -> None:
|
||||
event = await _reader(FakePrompt(lambda: "status\x1b[12;80R now")).read()
|
||||
|
||||
assert event == InputSubmitted("status now")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prompt_input_reader_ignores_cpr_only_input() -> None:
|
||||
event = await _reader(
|
||||
SequencePrompt(["\x1b[12;80R", "show status"]),
|
||||
).read()
|
||||
|
||||
assert event == InputSubmitted("show status")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for ReplSubprocessPresenter Rich markup escaping."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from surfaces.interactive_shell.runtime.subprocess_runner.repl_presenter import (
|
||||
ReplSubprocessPresenter,
|
||||
_escape_markup_message,
|
||||
)
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
def _presenter() -> tuple[ReplSubprocessPresenter, StringIO]:
|
||||
buffer = StringIO()
|
||||
console = Console(file=buffer, force_terminal=True, width=80, color_system=None)
|
||||
session = Session()
|
||||
return ReplSubprocessPresenter(session, console), buffer
|
||||
|
||||
|
||||
def test_escape_markup_message_preserves_intentional_tags() -> None:
|
||||
escaped = _escape_markup_message("[error]failed[/]")
|
||||
buffer = StringIO()
|
||||
Console(file=buffer, force_terminal=True, width=80, color_system=None).print(escaped)
|
||||
output = buffer.getvalue()
|
||||
assert "failed" in output
|
||||
|
||||
|
||||
def test_escape_markup_message_escapes_plain_markup() -> None:
|
||||
escaped = _escape_markup_message("Grafana [prod] rate[5m]")
|
||||
buffer = StringIO()
|
||||
Console(file=buffer, force_terminal=True, width=80, color_system=None).print(escaped)
|
||||
output = buffer.getvalue()
|
||||
assert "[prod]" in output
|
||||
assert "rate[5m]" in output
|
||||
|
||||
|
||||
def test_escape_markup_message_escapes_dynamic_text_inside_tags() -> None:
|
||||
escaped = _escape_markup_message("[bold]task [critical][/bold]")
|
||||
assert "task" in escaped
|
||||
assert "[critical]" in escaped
|
||||
|
||||
|
||||
def test_print_error_escapes_dynamic_markup() -> None:
|
||||
presenter, buffer = _presenter()
|
||||
presenter.print_error("failed: [bold]bad[/]")
|
||||
output = buffer.getvalue()
|
||||
assert "[bold]bad[/]" in output
|
||||
assert "bad" in output
|
||||
|
||||
|
||||
def test_print_escapes_untrusted_suffix_via_print_error_pattern() -> None:
|
||||
presenter, buffer = _presenter()
|
||||
presenter.print_error("command failed to start: [bold]bad[/]")
|
||||
output = buffer.getvalue()
|
||||
assert "[bold]bad[/]" in output
|
||||
|
||||
|
||||
def test_print_preserves_task_id_markup_with_escaped_brackets() -> None:
|
||||
presenter, buffer = _presenter()
|
||||
task_id = "task-[critical]"
|
||||
presenter.print(
|
||||
f"[dim]synthetic test started — task[/] [bold]{task_id}[/bold]. "
|
||||
"[highlight]/tasks[/] [dim]to monitor.[/]"
|
||||
)
|
||||
output = buffer.getvalue()
|
||||
assert task_id in output
|
||||
assert "/tasks" in output
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Tests for Session state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import config.constants as const_module
|
||||
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
from platform.common.task_types import TaskKind
|
||||
from surfaces.interactive_shell.session import (
|
||||
Session,
|
||||
)
|
||||
from surfaces.interactive_shell.session.session import _scenario_id_from_synthetic_label
|
||||
|
||||
|
||||
class TestSession:
|
||||
def test_defaults(self) -> None:
|
||||
session = Session()
|
||||
assert session.history == []
|
||||
assert session.last_state is None
|
||||
assert session.accumulated_context == {}
|
||||
assert session.terminal.trust_mode is False
|
||||
assert session.task_registry.list_recent() == []
|
||||
assert session.terminal.metrics.turn_count == 0
|
||||
assert session.terminal.metrics.fallback_count == 0
|
||||
assert session.terminal.metrics.ctrl_c_intervention_count == 0
|
||||
assert session.terminal.metrics.correction_intervention_count == 0
|
||||
assert session.terminal.pending_prompt_default is None
|
||||
assert session.last_synthetic_observation_path is None
|
||||
|
||||
def test_take_pending_prompt_default_returns_and_clears(self) -> None:
|
||||
session = Session()
|
||||
session.terminal.pending_prompt_default = "why did it fail?"
|
||||
assert session.terminal.pop_pending_prompt_default() == "why did it fail?"
|
||||
assert session.terminal.pending_prompt_default is None
|
||||
assert session.terminal.pop_pending_prompt_default() == ""
|
||||
|
||||
def test_clear_resets_pending_prompt_default(self) -> None:
|
||||
session = Session()
|
||||
session.terminal.pending_prompt_default = "why did it fail?"
|
||||
session.clear()
|
||||
assert session.terminal.pending_prompt_default is None
|
||||
|
||||
def test_queue_auto_command_sets_pending_and_notifies(self) -> None:
|
||||
session = Session()
|
||||
calls: list[bool] = []
|
||||
session.terminal.prompt_refresh_fn = lambda: calls.append(True)
|
||||
session.terminal.set_auto_command("/integrations setup sentry")
|
||||
assert session.terminal.pending_prompt_default == "/integrations setup sentry"
|
||||
assert session.terminal.pending_prompt_autosubmit is True
|
||||
assert calls == [True]
|
||||
|
||||
def test_take_pending_autosubmit_returns_and_clears(self) -> None:
|
||||
session = Session()
|
||||
session.terminal.pending_prompt_autosubmit = True
|
||||
assert session.terminal.pop_pending_autosubmit() is True
|
||||
assert session.terminal.pending_prompt_autosubmit is False
|
||||
assert session.terminal.pop_pending_autosubmit() is False
|
||||
|
||||
def test_clear_resets_pending_autosubmit(self) -> None:
|
||||
session = Session()
|
||||
session.terminal.set_auto_command("/integrations setup sentry")
|
||||
session.clear()
|
||||
assert session.terminal.pending_prompt_autosubmit is False
|
||||
assert session.terminal.pending_prompt_default is None
|
||||
|
||||
def test_scenario_id_from_synthetic_label(self) -> None:
|
||||
assert (
|
||||
_scenario_id_from_synthetic_label(
|
||||
"opensre tests synthetic --scenario 001-replication-lag"
|
||||
)
|
||||
== "001-replication-lag"
|
||||
)
|
||||
assert _scenario_id_from_synthetic_label("rds_postgres:001-replication-lag") == (
|
||||
"001-replication-lag"
|
||||
)
|
||||
assert _scenario_id_from_synthetic_label("opensre tests synthetic --scenario ./evil") == ""
|
||||
assert _scenario_id_from_synthetic_label("rds_postgres:not-a-scenario") == ""
|
||||
|
||||
def test_suggest_synthetic_failure_follow_up_sets_pending(self) -> None:
|
||||
session = Session()
|
||||
session.suggest_synthetic_failure_follow_up(
|
||||
label="opensre tests synthetic --scenario 001-replication-lag",
|
||||
)
|
||||
assert (
|
||||
session.terminal.pending_prompt_default == SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
)
|
||||
|
||||
def test_record_appends_entry(self) -> None:
|
||||
session = Session()
|
||||
session.record("alert", "cpu high")
|
||||
session.record("slash", "/status", ok=True)
|
||||
session.record("alert", "bad one", ok=False)
|
||||
assert len(session.history) == 3
|
||||
assert session.history[-1]["type"] == "alert"
|
||||
assert session.history[-1]["ok"] is False
|
||||
|
||||
def test_mark_latest_updates_most_recent_matching_kind(self) -> None:
|
||||
session = Session()
|
||||
session.record("slash", "/investigate missing.json")
|
||||
session.record("alert", "missing.json", ok=False)
|
||||
|
||||
session.mark_latest(ok=False, kind="slash")
|
||||
|
||||
assert session.history[0]["ok"] is False
|
||||
assert session.history[1]["ok"] is False
|
||||
|
||||
def test_clear_preserves_trust_mode(self) -> None:
|
||||
session = Session()
|
||||
session.terminal.trust_mode = True
|
||||
session.terminal.background_notification_preferences.set_channels(["email"])
|
||||
session.accumulated_context["service"] = "api"
|
||||
session.record("alert", "something")
|
||||
session.last_state = {"foo": "bar"}
|
||||
session.agent.messages.append(("user", "hey"))
|
||||
session.terminal.metrics.record_intervention("ctrl_c")
|
||||
session.terminal.metrics.record_intervention("correction")
|
||||
|
||||
assert session.terminal.history_generation == 0
|
||||
session.clear()
|
||||
assert session.terminal.history_generation == 1
|
||||
|
||||
assert session.history == []
|
||||
assert session.last_state is None
|
||||
assert session.accumulated_context == {}
|
||||
assert session.agent.messages == []
|
||||
assert session.task_registry.list_recent() == []
|
||||
assert session.terminal.metrics.ctrl_c_intervention_count == 0
|
||||
assert session.terminal.metrics.correction_intervention_count == 0
|
||||
assert session.terminal.background_notification_preferences.channels == ("email",)
|
||||
assert session.terminal.trust_mode is True # preserved intentionally
|
||||
|
||||
def test_clear_keeps_persisted_task_history_file(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = Session()
|
||||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||||
session.task_registry = TaskRegistry.persistent()
|
||||
task = session.task_registry.create(
|
||||
TaskKind.SYNTHETIC_TEST, command="opensre tests synthetic"
|
||||
)
|
||||
task.mark_running()
|
||||
|
||||
session.clear()
|
||||
|
||||
reloaded = TaskRegistry.persistent()
|
||||
loaded = reloaded.get(task.task_id)
|
||||
assert loaded is not None
|
||||
assert loaded.task_id == task.task_id
|
||||
|
||||
def test_accumulate_from_state_extracts_known_keys(self) -> None:
|
||||
session = Session()
|
||||
session.accumulate_from_state(
|
||||
{
|
||||
"service": "orders-api",
|
||||
"pipeline_name": "events_fact",
|
||||
"cluster_name": "prod-us-east",
|
||||
"region": "us-east-1",
|
||||
"environment": "production",
|
||||
"root_cause": "disk full", # not accumulated
|
||||
"evidence": {"ev-1": "x"}, # not accumulated
|
||||
}
|
||||
)
|
||||
assert session.accumulated_context == {
|
||||
"service": "orders-api",
|
||||
"pipeline_name": "events_fact",
|
||||
"cluster_name": "prod-us-east",
|
||||
"region": "us-east-1",
|
||||
"environment": "production",
|
||||
}
|
||||
|
||||
def test_accumulate_from_state_skips_empty_and_none(self) -> None:
|
||||
session = Session()
|
||||
session.accumulate_from_state(
|
||||
{
|
||||
"service": "",
|
||||
"cluster_name": None,
|
||||
"region": "us-east-1",
|
||||
}
|
||||
)
|
||||
assert session.accumulated_context == {"region": "us-east-1"}
|
||||
|
||||
def test_accumulate_from_state_merges_across_calls(self) -> None:
|
||||
"""Subsequent investigations fill in context the earlier one didn't have."""
|
||||
session = Session()
|
||||
session.accumulate_from_state({"service": "orders-api"})
|
||||
session.accumulate_from_state({"cluster_name": "prod-us-east"})
|
||||
assert session.accumulated_context == {
|
||||
"service": "orders-api",
|
||||
"cluster_name": "prod-us-east",
|
||||
}
|
||||
|
||||
def test_accumulate_from_state_handles_none_and_empty_state(self) -> None:
|
||||
session = Session()
|
||||
session.accumulate_from_state(None)
|
||||
session.accumulate_from_state({})
|
||||
assert session.accumulated_context == {}
|
||||
|
||||
def test_record_terminal_turn_updates_aggregates(self) -> None:
|
||||
session = Session()
|
||||
|
||||
first = session.terminal.metrics.record_turn(
|
||||
executed_count=2,
|
||||
executed_success_count=1,
|
||||
fallback_to_llm=True,
|
||||
)
|
||||
second = session.terminal.metrics.record_turn(
|
||||
executed_count=1,
|
||||
executed_success_count=1,
|
||||
fallback_to_llm=False,
|
||||
)
|
||||
|
||||
assert first.turn_index == 1
|
||||
assert first.fallback_count == 1
|
||||
assert first.action_success_percent == 50.0
|
||||
assert first.fallback_rate_percent == 100.0
|
||||
|
||||
assert second.turn_index == 2
|
||||
assert second.fallback_count == 1
|
||||
assert round(second.action_success_percent, 2) == 66.67
|
||||
assert second.fallback_rate_percent == 50.0
|
||||
|
||||
def test_record_intervention_increments_per_kind(self) -> None:
|
||||
session = Session()
|
||||
|
||||
session.terminal.metrics.record_intervention("ctrl_c")
|
||||
session.terminal.metrics.record_intervention("ctrl_c")
|
||||
session.terminal.metrics.record_intervention("correction")
|
||||
|
||||
assert session.terminal.metrics.ctrl_c_intervention_count == 2
|
||||
assert session.terminal.metrics.correction_intervention_count == 1
|
||||
|
||||
def test_record_intervention_kinds_are_independent(self) -> None:
|
||||
"""Incrementing one kind does not touch the other."""
|
||||
session = Session()
|
||||
|
||||
session.terminal.metrics.record_intervention("correction")
|
||||
|
||||
assert session.terminal.metrics.ctrl_c_intervention_count == 0
|
||||
assert session.terminal.metrics.correction_intervention_count == 1
|
||||
|
||||
def test_fresh_session_starts_with_zero_intervention_counts(self) -> None:
|
||||
"""A new Session does not inherit any prior session's counters."""
|
||||
first = Session()
|
||||
first.terminal.metrics.record_intervention("ctrl_c")
|
||||
first.terminal.metrics.record_intervention("correction")
|
||||
|
||||
second = Session()
|
||||
|
||||
assert second.terminal.metrics.ctrl_c_intervention_count == 0
|
||||
assert second.terminal.metrics.correction_intervention_count == 0
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Tests for the inline turn spinner in runtime.core.state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
from surfaces.interactive_shell.runtime.core.state import SpinnerState
|
||||
|
||||
_GLYPHS = SpinnerState._SPINNER_FRAMES
|
||||
|
||||
|
||||
def _glyph(spinner: SpinnerState) -> str:
|
||||
rendered = spinner.inline_spinner_ansi()
|
||||
match = re.search("|".join(map(re.escape, _GLYPHS)), rendered)
|
||||
assert match is not None, f"no spinner glyph in {rendered!r}"
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def test_spinner_frame_is_a_function_of_elapsed_time_not_call_count() -> None:
|
||||
"""Regression: the glyph must animate no matter how often it is rendered.
|
||||
|
||||
prompt_toolkit evaluates the prompt message callable several times per
|
||||
render pass. A per-call frame counter advanced exactly one full cycle per
|
||||
visible render (10 evals x 10 frames), so the on-screen glyph never
|
||||
changed. Deriving the frame from elapsed time makes repeated evaluations
|
||||
idempotent and guarantees the animation advances between renders.
|
||||
"""
|
||||
spinner = SpinnerState()
|
||||
spinner.start()
|
||||
|
||||
# Repeated evaluations inside one render pass: same frame every time.
|
||||
first = _glyph(spinner)
|
||||
assert all(_glyph(spinner) == first for _ in range(10))
|
||||
|
||||
# A frame interval later the glyph must have advanced.
|
||||
spinner.started_at -= SpinnerState._FRAME_INTERVAL_S * 1.01
|
||||
assert _glyph(spinner) != first
|
||||
|
||||
# Over a full cycle of elapsed time, every frame is visited in order.
|
||||
spinner.start()
|
||||
seen = []
|
||||
for step in range(len(_GLYPHS)):
|
||||
spinner.started_at = time.monotonic() - step * SpinnerState._FRAME_INTERVAL_S * 1.001
|
||||
seen.append(_glyph(spinner))
|
||||
assert seen == list(_GLYPHS)
|
||||
|
||||
|
||||
def test_spinner_renders_elapsed_seconds_and_cancel_hint() -> None:
|
||||
spinner = SpinnerState()
|
||||
spinner.start()
|
||||
spinner.started_at = time.monotonic() - 8.2
|
||||
|
||||
rendered = spinner.inline_spinner_ansi()
|
||||
|
||||
assert "(8s)" in rendered
|
||||
assert "esc to cancel" in rendered
|
||||
|
||||
|
||||
def test_spinner_empty_when_not_streaming() -> None:
|
||||
spinner = SpinnerState()
|
||||
assert spinner.inline_spinner_ansi() == ""
|
||||
spinner.start()
|
||||
spinner.stop()
|
||||
assert spinner.inline_spinner_ansi() == ""
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
|
||||
from surfaces.interactive_shell.runtime.background.workers import BackgroundTaskManager
|
||||
from surfaces.interactive_shell.runtime.core.state import ReplState, SpinnerState
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_spinner_ticker_invalidates_once_after_streaming_stops(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The ticker repaints while streaming and once more on the streaming->idle edge.
|
||||
|
||||
Without the trailing invalidation a stopped investigation would leave the
|
||||
last phase label rendered on the prompt until unrelated I/O redraws it.
|
||||
"""
|
||||
state = ReplState()
|
||||
spinner = SpinnerState()
|
||||
calls: list[int] = []
|
||||
manager = BackgroundTaskManager(
|
||||
cast(Session, cast(Any, object())),
|
||||
state,
|
||||
spinner,
|
||||
None,
|
||||
lambda: calls.append(1),
|
||||
)
|
||||
|
||||
ticks = 0
|
||||
real_sleep = asyncio.sleep
|
||||
|
||||
async def fake_sleep(_seconds: float) -> None:
|
||||
nonlocal ticks
|
||||
ticks += 1
|
||||
if ticks == 1:
|
||||
spinner.set_phase("Investigation") # streaming on
|
||||
elif ticks == 3:
|
||||
spinner.stop() # streaming off (investigation done)
|
||||
elif ticks >= 5:
|
||||
state.exit_requested = True
|
||||
await real_sleep(0)
|
||||
|
||||
monkeypatch.setattr(asyncio, "sleep", fake_sleep)
|
||||
await manager._spinner_ticker()
|
||||
|
||||
# ticks 1-2 stream (2 invalidations) + one trailing edge at tick 3, then
|
||||
# silence while idle at tick 4.
|
||||
assert len(calls) == 3
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
||||
"""Tests for REPL task registry and /tasks · /cancel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from rich.console import Console
|
||||
|
||||
from config.constants.prompts import SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
from platform.common.task_registry import TaskRegistry
|
||||
from platform.common.task_types import TaskKind, TaskStatus
|
||||
from surfaces.interactive_shell.command_registry import dispatch_slash
|
||||
from surfaces.interactive_shell.session import (
|
||||
Session,
|
||||
)
|
||||
from tools.interactive_shell.synthetic.runner import watch_synthetic_subprocess
|
||||
|
||||
|
||||
def _capture() -> tuple[Console, io.StringIO]:
|
||||
buf = io.StringIO()
|
||||
return Console(file=buf, force_terminal=False, highlight=False), buf
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stderr_buf() -> Iterator[tempfile.SpooledTemporaryFile]: # type: ignore[type-arg]
|
||||
"""Stderr buffer for synthetic-watcher tests.
|
||||
|
||||
The watcher's ``finally`` block closes the buffer; the ``with`` here
|
||||
is the belt-and-braces close that runs when a test exits before the
|
||||
watcher does (e.g. deferred-thread tests where ``pending[0]()`` is
|
||||
never invoked).
|
||||
"""
|
||||
with tempfile.SpooledTemporaryFile() as buf:
|
||||
yield buf
|
||||
|
||||
|
||||
class TestTaskRecord:
|
||||
def test_lifecycle_completed(self) -> None:
|
||||
reg = TaskRegistry()
|
||||
t = reg.create(TaskKind.INVESTIGATION)
|
||||
assert t.status == TaskStatus.PENDING
|
||||
t.mark_running()
|
||||
assert t.status == TaskStatus.RUNNING
|
||||
t.mark_completed(result="done")
|
||||
assert t.status == TaskStatus.COMPLETED
|
||||
assert t.result == "done"
|
||||
assert t.ended_at is not None
|
||||
t.mark_failed("x")
|
||||
assert t.status == TaskStatus.COMPLETED
|
||||
|
||||
def test_mark_cancelled_idempotent_after_terminal(self) -> None:
|
||||
reg = TaskRegistry()
|
||||
t = reg.create(TaskKind.INVESTIGATION)
|
||||
t.mark_running()
|
||||
t.mark_cancelled()
|
||||
assert t.status == TaskStatus.CANCELLED
|
||||
t.mark_completed(result="nope")
|
||||
assert t.status == TaskStatus.CANCELLED
|
||||
|
||||
def test_request_cancel_sets_event_even_when_pending(self) -> None:
|
||||
reg = TaskRegistry()
|
||||
t = reg.create(TaskKind.INVESTIGATION)
|
||||
assert t.request_cancel() is False
|
||||
assert t.cancel_requested.is_set()
|
||||
assert t.status == TaskStatus.PENDING
|
||||
|
||||
def test_request_cancel_sets_event_and_terminates_process(self) -> None:
|
||||
reg = TaskRegistry()
|
||||
t = reg.create(TaskKind.SYNTHETIC_TEST)
|
||||
t.mark_running()
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
t.attach_process(proc)
|
||||
assert t.request_cancel() is True
|
||||
proc.terminate.assert_called_once()
|
||||
assert t.cancel_requested.is_set()
|
||||
|
||||
|
||||
class TestTaskRegistry:
|
||||
def test_get_single_prefix_match(self) -> None:
|
||||
reg = TaskRegistry()
|
||||
t = reg.create(TaskKind.INVESTIGATION)
|
||||
assert reg.get(t.task_id[:4]) == t
|
||||
assert reg.get("") is None
|
||||
|
||||
def test_candidates_ambiguous_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
_ids = iter(["11111111", "11112222"])
|
||||
|
||||
def _fake_hex(_nbytes: int) -> str:
|
||||
return next(_ids)
|
||||
|
||||
monkeypatch.setattr("platform.common.task_registry.secrets.token_hex", _fake_hex)
|
||||
session = Session()
|
||||
session.task_registry.create(TaskKind.INVESTIGATION)
|
||||
session.task_registry.create(TaskKind.INVESTIGATION)
|
||||
console, buf = _capture()
|
||||
dispatch_slash("/cancel 1111", session, console)
|
||||
assert "ambiguous" in buf.getvalue().lower()
|
||||
|
||||
def test_ring_buffer_drops_oldest(self) -> None:
|
||||
reg = TaskRegistry(max_tasks=3)
|
||||
first = reg.create(TaskKind.INVESTIGATION)
|
||||
reg.create(TaskKind.INVESTIGATION)
|
||||
reg.create(TaskKind.INVESTIGATION)
|
||||
reg.create(TaskKind.INVESTIGATION)
|
||||
recent_ids = [t.task_id for t in reg.list_recent(10)]
|
||||
assert first.task_id not in recent_ids
|
||||
assert len(recent_ids) == 3
|
||||
|
||||
def test_persistent_registry_reloads_running_pid(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import config.constants as const_module
|
||||
|
||||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||||
reg = TaskRegistry.persistent()
|
||||
task = reg.create(TaskKind.SYNTHETIC_TEST, command="opensre tests synthetic")
|
||||
task.mark_running()
|
||||
task.attach_pid(os.getpid())
|
||||
|
||||
reloaded = TaskRegistry.persistent()
|
||||
[loaded] = reloaded.list_recent()
|
||||
assert loaded.task_id == task.task_id
|
||||
assert loaded.status == TaskStatus.RUNNING
|
||||
assert loaded.pid == os.getpid()
|
||||
assert loaded.command == "opensre tests synthetic"
|
||||
|
||||
def test_persistent_registry_marks_missing_pid_finished(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import config.constants as const_module
|
||||
|
||||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||||
store_path = tmp_path / "interactive_tasks.json"
|
||||
store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
store_path.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{
|
||||
"task_id": "abc12345",
|
||||
"kind": "synthetic_test",
|
||||
"status": "running",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
"result": None,
|
||||
"error": None,
|
||||
"pid": 999_999,
|
||||
"command": "opensre tests synthetic",
|
||||
}
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"platform.common.task_types.os.kill",
|
||||
lambda _pid, _sig: (_ for _ in ()).throw(ProcessLookupError()),
|
||||
)
|
||||
|
||||
reloaded = TaskRegistry.persistent()
|
||||
[loaded] = reloaded.list_recent()
|
||||
assert loaded.status == TaskStatus.COMPLETED
|
||||
assert loaded.result == "process exited while shell was closed"
|
||||
|
||||
def test_cancel_rehydrated_task_does_not_signal_pid(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import config.constants as const_module
|
||||
|
||||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||||
calls: list[tuple[int, int]] = []
|
||||
|
||||
def _fake_kill(pid: int, sig: int) -> None:
|
||||
calls.append((pid, sig))
|
||||
|
||||
monkeypatch.setattr("platform.common.task_types.os.kill", _fake_kill)
|
||||
reg = TaskRegistry.persistent()
|
||||
task = reg.create(TaskKind.SYNTHETIC_TEST, command="opensre tests synthetic")
|
||||
task.mark_running()
|
||||
task.attach_pid(12345)
|
||||
|
||||
reloaded = TaskRegistry.persistent()
|
||||
loaded = reloaded.get(task.task_id)
|
||||
assert loaded is not None
|
||||
assert loaded.request_cancel() is True
|
||||
assert loaded.status == TaskStatus.CANCELLED
|
||||
assert (12345, 15) not in calls
|
||||
|
||||
def test_session_new_does_not_truncate_persistent_task_store(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
import config.constants as const_module
|
||||
|
||||
monkeypatch.setattr(const_module, "OPENSRE_HOME_DIR", tmp_path)
|
||||
session = Session()
|
||||
session.task_registry = TaskRegistry.persistent()
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST, command="opensre tests")
|
||||
task.mark_running()
|
||||
task.mark_completed(result="ok")
|
||||
|
||||
session.clear()
|
||||
|
||||
# /new must keep the on-disk store intact: a fresh persistent registry
|
||||
# still finds the task, and the session's swapped-in registry continues
|
||||
# to surface persisted history via its disk-backed merge so /tasks does
|
||||
# not "forget" the user's prior runs after /new.
|
||||
reloaded = TaskRegistry.persistent()
|
||||
[loaded] = reloaded.list_recent()
|
||||
assert loaded.task_id == task.task_id
|
||||
assert loaded.command == "opensre tests"
|
||||
[visible_after_new] = session.task_registry.list_recent()
|
||||
assert visible_after_new.task_id == task.task_id
|
||||
|
||||
|
||||
class TestSlashTaskCommands:
|
||||
def test_tasks_empty_message(self) -> None:
|
||||
session = Session()
|
||||
console, buf = _capture()
|
||||
dispatch_slash("/tasks", session, console)
|
||||
assert "no tasks" in buf.getvalue().lower()
|
||||
|
||||
def test_tasks_shows_recent_rows(self) -> None:
|
||||
session = Session()
|
||||
t = session.task_registry.create(TaskKind.INVESTIGATION)
|
||||
t.mark_running()
|
||||
t.mark_completed(result="rc")
|
||||
console, buf = _capture()
|
||||
dispatch_slash("/tasks", session, console)
|
||||
out = buf.getvalue()
|
||||
assert t.task_id in out
|
||||
assert "investigation" in out
|
||||
assert "completed" in out
|
||||
|
||||
def test_cancel_usage_without_id(self) -> None:
|
||||
session = Session()
|
||||
console, buf = _capture()
|
||||
dispatch_slash("/cancel", session, console)
|
||||
assert "usage" in buf.getvalue().lower()
|
||||
|
||||
def test_cancel_unknown_id(self) -> None:
|
||||
session = Session()
|
||||
console, buf = _capture()
|
||||
dispatch_slash("/cancel deadbeef", session, console)
|
||||
assert "no task" in buf.getvalue().lower()
|
||||
|
||||
def test_cancel_completed_task_message(self) -> None:
|
||||
session = Session()
|
||||
t = session.task_registry.create(TaskKind.INVESTIGATION)
|
||||
t.mark_running()
|
||||
t.mark_completed(result="x")
|
||||
console, buf = _capture()
|
||||
dispatch_slash(f"/cancel {t.task_id}", session, console)
|
||||
assert "already finished" in buf.getvalue().lower()
|
||||
|
||||
def test_cancel_running_investigation_signals(self) -> None:
|
||||
session = Session()
|
||||
t = session.task_registry.create(TaskKind.INVESTIGATION)
|
||||
t.mark_running()
|
||||
console, buf = _capture()
|
||||
dispatch_slash(f"/cancel {t.task_id}", session, console)
|
||||
out = buf.getvalue()
|
||||
assert "cancellation" in out.lower()
|
||||
assert "Ctrl+C" in out
|
||||
|
||||
def test_cancel_running_synthetic_signals_and_terminates_process(self) -> None:
|
||||
session = Session()
|
||||
t = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
t.mark_running()
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
t.attach_process(proc)
|
||||
console, buf = _capture()
|
||||
dispatch_slash(f"/cancel {t.task_id}", session, console)
|
||||
assert t.cancel_requested.is_set()
|
||||
proc.terminate.assert_called_once()
|
||||
out = buf.getvalue()
|
||||
assert "stop requested" in out.lower()
|
||||
assert t.task_id in out
|
||||
|
||||
|
||||
class _ImmediateThread:
|
||||
"""Run ``target`` synchronously inside ``start()`` (for deterministic tests)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: object = None,
|
||||
target: Callable[[], None] | None = None,
|
||||
name: object = None,
|
||||
args: tuple[object, ...] = (),
|
||||
kwargs: dict[str, object] | None = None,
|
||||
*,
|
||||
daemon: object = None,
|
||||
) -> None:
|
||||
del group, args, kwargs, daemon, name # threaded API baggage
|
||||
if target is None:
|
||||
raise TypeError("target required")
|
||||
self._target = target
|
||||
|
||||
def start(self) -> None:
|
||||
self._target()
|
||||
|
||||
|
||||
class _DeferredSyntheticThread:
|
||||
"""Queue ``target`` via ``start()``; tests invoke queued callables explicitly."""
|
||||
|
||||
pending: list[Callable[[], None]] = []
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
group: object = None,
|
||||
target: Callable[[], None] | None = None,
|
||||
name: object = None,
|
||||
args: tuple[object, ...] = (),
|
||||
kwargs: dict[str, object] | None = None,
|
||||
*,
|
||||
daemon: object = None,
|
||||
) -> None:
|
||||
del group, args, kwargs, daemon, name
|
||||
if target is None:
|
||||
raise TypeError("target required")
|
||||
self._target = target
|
||||
|
||||
def start(self) -> None:
|
||||
_DeferredSyntheticThread.pending.append(self._target)
|
||||
|
||||
|
||||
def _synthetic_presenter(session: Session) -> MagicMock:
|
||||
presenter = MagicMock()
|
||||
presenter.session = session
|
||||
presenter.start_task_output_streams.return_value = []
|
||||
presenter.join_task_output_streams.return_value = None
|
||||
presenter.report_exception.return_value = None
|
||||
presenter.print_error.return_value = None
|
||||
return presenter
|
||||
|
||||
|
||||
class TestSyntheticSubprocessWatcher:
|
||||
def test_watch_marks_completed_when_process_already_done(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_ImmediateThread,
|
||||
)
|
||||
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
task.attach_process(proc)
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
hist = session.history[-1]
|
||||
assert hist["type"] == "synthetic_test"
|
||||
assert hist["ok"] is True
|
||||
assert "task:" in hist["text"]
|
||||
|
||||
def test_watch_honours_exit_code_when_cancel_races_loop_exit(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
"""Process exits naturally (code 0) in the same poll tick that /cancel fires.
|
||||
|
||||
The poll loop exits because proc.poll() returns non-None *before* the
|
||||
cancel_requested branch runs, so terminated_by_watcher stays False.
|
||||
The task must be COMPLETED, not CANCELLED — the process succeeded.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_ImmediateThread,
|
||||
)
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
proc = MagicMock()
|
||||
|
||||
# poll() returns None once (enter loop body), then cancel fires via
|
||||
# sleep, which also makes poll return 0 — loop exits via while condition.
|
||||
pending: list[int | None] = [None]
|
||||
|
||||
def _poll_side() -> int | None:
|
||||
return pending[0]
|
||||
|
||||
proc.poll.side_effect = _poll_side
|
||||
proc.returncode = 0
|
||||
|
||||
sleeps: list[float] = []
|
||||
|
||||
def _fake_sleep(_secs: float) -> None:
|
||||
sleeps.append(_secs)
|
||||
task.cancel_requested.set()
|
||||
pending[0] = 0 # process finishes naturally in the same window
|
||||
|
||||
monkeypatch.setattr("tools.interactive_shell.subprocess.time.sleep", _fake_sleep)
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
# terminated_by_watcher is False → honour exit code 0 → COMPLETED
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
assert sleeps
|
||||
|
||||
def test_watch_marks_cancelled_when_watcher_kills_process(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
"""cancel_requested is set while proc is still running; watcher terminates it."""
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_ImmediateThread,
|
||||
)
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
proc = MagicMock()
|
||||
|
||||
# poll() always returns None so the watcher's cancel branch runs and
|
||||
# calls _terminate_child_process; returncode is set after that.
|
||||
proc.poll.return_value = None
|
||||
proc.returncode = -15
|
||||
|
||||
task.cancel_requested.set() # cancel already set before first loop check
|
||||
|
||||
# Skip the sleep so the loop iterates immediately to the cancel branch.
|
||||
monkeypatch.setattr("tools.interactive_shell.subprocess.time.sleep", lambda _: None)
|
||||
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
assert task.status == TaskStatus.CANCELLED
|
||||
hist = session.history[-1]
|
||||
assert hist["type"] == "synthetic_test"
|
||||
assert hist["ok"] is False
|
||||
|
||||
def test_watch_honours_exit_code_when_cancel_races_natural_exit(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
"""Process exits naturally (code 0) while /cancel fires concurrently.
|
||||
|
||||
The watcher should mark the task COMPLETED, not CANCELLED, because we
|
||||
never called _terminate_child_process — the process was already gone.
|
||||
"""
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_ImmediateThread,
|
||||
)
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
proc = MagicMock()
|
||||
# Process already finished; poll returns non-None immediately so the
|
||||
# while-loop body never executes — terminated_by_watcher stays False.
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
|
||||
# Simulate /cancel arriving just as the watcher reads poll()
|
||||
task.cancel_requested.set()
|
||||
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
assert task.status == TaskStatus.COMPLETED
|
||||
|
||||
def test_watch_captures_stderr_on_failure(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
"""Diagnostic stderr output is included in mark_failed message."""
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_ImmediateThread,
|
||||
)
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 1
|
||||
proc.returncode = 1
|
||||
|
||||
stderr_buf.write(b"ConnectionError: database unreachable\n")
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
assert task.status == TaskStatus.FAILED
|
||||
assert "exit code 1" in (task.error or "")
|
||||
assert "ConnectionError" in (task.error or "")
|
||||
assert (
|
||||
session.terminal.pending_prompt_default == SUGGESTED_PROMPT_AFTER_FAILED_SYNTHETIC_TEST
|
||||
)
|
||||
|
||||
def test_watch_skips_synthetic_history_after_reset(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
_DeferredSyntheticThread.pending.clear()
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_DeferredSyntheticThread,
|
||||
)
|
||||
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
task.attach_process(proc)
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
assert len(_DeferredSyntheticThread.pending) == 1
|
||||
session.clear()
|
||||
_DeferredSyntheticThread.pending[0]()
|
||||
assert session.history == []
|
||||
_DeferredSyntheticThread.pending.clear()
|
||||
|
||||
def test_deferred_watcher_writes_history_when_no_reset(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
stderr_buf: tempfile.SpooledTemporaryFile, # type: ignore[type-arg]
|
||||
) -> None:
|
||||
_DeferredSyntheticThread.pending.clear()
|
||||
monkeypatch.setattr(
|
||||
"tools.interactive_shell.synthetic.runner.threading.Thread",
|
||||
_DeferredSyntheticThread,
|
||||
)
|
||||
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = 0
|
||||
proc.returncode = 0
|
||||
|
||||
session = Session()
|
||||
presenter = _synthetic_presenter(session)
|
||||
task = session.task_registry.create(TaskKind.SYNTHETIC_TEST)
|
||||
task.mark_running()
|
||||
task.attach_process(proc)
|
||||
watch_synthetic_subprocess(task, proc, presenter, "rds_postgres", stderr_buf)
|
||||
_DeferredSyntheticThread.pending[0]()
|
||||
assert session.history[-1]["type"] == "synthetic_test"
|
||||
_DeferredSyntheticThread.pending.clear()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Tests for interactive-shell session token accounting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from rich.console import Console
|
||||
|
||||
from core.agent_harness.accounting.token_accounting import (
|
||||
build_llm_run_info,
|
||||
estimate_tokens,
|
||||
format_token_total,
|
||||
record_llm_turn,
|
||||
)
|
||||
from surfaces.interactive_shell.runtime.answer_turn import answer_shell_question
|
||||
from surfaces.interactive_shell.session import Session
|
||||
from surfaces.interactive_shell.ui.streaming import _CHARS_PER_TOKEN
|
||||
|
||||
|
||||
def test_estimate_tokens_uses_chars_per_token_ratio() -> None:
|
||||
text = "x" * (8 * _CHARS_PER_TOKEN)
|
||||
assert estimate_tokens(text) == 8
|
||||
|
||||
|
||||
def test_record_token_usage_accumulates_on_session() -> None:
|
||||
session = Session()
|
||||
record_llm_turn(session, prompt="a" * 40, response="b" * 20)
|
||||
assert session.tokens.totals == {
|
||||
"input": 10,
|
||||
"output": 5,
|
||||
"input_estimated": 10,
|
||||
"output_estimated": 5,
|
||||
}
|
||||
assert session.tokens.call_count == 1
|
||||
assert session.tokens.has_estimates is True
|
||||
record_llm_turn(session, prompt="c" * 8, response="d" * 4)
|
||||
assert session.tokens.totals == {
|
||||
"input": 12,
|
||||
"output": 6,
|
||||
"input_estimated": 12,
|
||||
"output_estimated": 6,
|
||||
}
|
||||
assert session.tokens.call_count == 2
|
||||
|
||||
|
||||
def test_record_llm_turn_uses_provider_counts_when_present() -> None:
|
||||
session = Session()
|
||||
inp, out, estimated = record_llm_turn(
|
||||
session,
|
||||
prompt="ignored when counts supplied",
|
||||
response="also ignored",
|
||||
input_tokens=1200,
|
||||
output_tokens=80,
|
||||
)
|
||||
assert (inp, out, estimated) == (1200, 80, False)
|
||||
assert session.tokens.totals == {
|
||||
"input": 1200,
|
||||
"output": 80,
|
||||
"input_measured": 1200,
|
||||
"output_measured": 80,
|
||||
}
|
||||
assert session.tokens.has_estimates is False
|
||||
|
||||
|
||||
def test_format_token_total_shows_mixed_breakdown() -> None:
|
||||
session = Session()
|
||||
record_llm_turn(
|
||||
session,
|
||||
prompt="x",
|
||||
response="y",
|
||||
input_tokens=300,
|
||||
output_tokens=40,
|
||||
)
|
||||
record_llm_turn(session, prompt="a" * 400, response="b" * 80)
|
||||
assert format_token_total(session, direction="input") == (
|
||||
"input tokens",
|
||||
"400 (300 provider + 100 est.)",
|
||||
)
|
||||
assert format_token_total(session, direction="output") == (
|
||||
"output tokens",
|
||||
"60 (40 provider + 20 est.)",
|
||||
)
|
||||
|
||||
|
||||
def test_build_llm_run_info_records_tokens_and_metadata() -> None:
|
||||
session = Session()
|
||||
|
||||
class _Client:
|
||||
_model = "claude-test"
|
||||
_provider_label = "Anthropic"
|
||||
|
||||
run = build_llm_run_info(
|
||||
session=session,
|
||||
prompt="a" * 40,
|
||||
response_text="b" * 20,
|
||||
client=_Client(),
|
||||
started=time.monotonic() - 0.05,
|
||||
)
|
||||
assert run.model == "claude-test"
|
||||
assert run.provider == "anthropic"
|
||||
assert run.input_tokens == 10
|
||||
assert run.output_tokens == 5
|
||||
assert run.response_text == "b" * 20
|
||||
assert run.latency_ms is not None and run.latency_ms >= 0
|
||||
|
||||
|
||||
def test_coerce_usage_tokens_accepts_float_counts() -> None:
|
||||
from core.llm.shared.usage import coerce_usage_tokens
|
||||
|
||||
assert coerce_usage_tokens(
|
||||
{"input_tokens": 512.0, "output_tokens": 64.0},
|
||||
input_key="input_tokens",
|
||||
output_key="output_tokens",
|
||||
) == (512, 64)
|
||||
|
||||
|
||||
def test_record_token_usage_skips_zero_counts() -> None:
|
||||
session = Session()
|
||||
session.tokens.record()
|
||||
assert session.tokens.totals == {}
|
||||
assert session.tokens.call_count == 0
|
||||
|
||||
|
||||
class _FakeLLMClient:
|
||||
def __init__(self, content: str) -> None:
|
||||
self._content = content
|
||||
self.last_prompt: str | None = None
|
||||
|
||||
def invoke_stream(self, prompt: str) -> Iterator[str]:
|
||||
self.last_prompt = prompt
|
||||
yield self._content
|
||||
|
||||
|
||||
def test_answer_shell_question_records_session_token_usage(monkeypatch: Any) -> None:
|
||||
client = _FakeLLMClient("assistant reply")
|
||||
monkeypatch.setattr("core.llm.factory.get_llm", lambda _role: client)
|
||||
session = Session()
|
||||
console = Console(file=io.StringIO(), force_terminal=False)
|
||||
answer_shell_question("hello", session, console)
|
||||
assert session.tokens.totals["input"] > 0
|
||||
assert session.tokens.totals["output"] == estimate_tokens("assistant reply")
|
||||
assert session.tokens.has_estimates is True
|
||||
@@ -0,0 +1,152 @@
|
||||
"""Tests for ShellTurnAccounting's pending turn LLM/error consumption."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from core.agent_harness.accounting.token_accounting import LlmRunInfo
|
||||
from core.agent_harness.turns.turn_results import ShellTurnResult, ToolCallingTurnResult
|
||||
from surfaces.interactive_shell.runtime.core.turn_accounting import ShellTurnAccounting
|
||||
from surfaces.interactive_shell.session import Session
|
||||
|
||||
|
||||
class _FakeRecorder:
|
||||
def __init__(self) -> None:
|
||||
self.errors: list[tuple[str, str]] = []
|
||||
self.responses: list[tuple[str, Any]] = []
|
||||
self.flushed = 0
|
||||
|
||||
def set_error(self, kind: str, message: str) -> None:
|
||||
self.errors.append((kind, message))
|
||||
|
||||
def set_response(self, text: str, run: Any | None = None) -> None:
|
||||
self.responses.append((text, run))
|
||||
|
||||
def flush(self) -> None:
|
||||
self.flushed += 1
|
||||
|
||||
|
||||
def _result(*, llm_run: Any | None = None, text: str = "done") -> ShellTurnResult:
|
||||
return ShellTurnResult(
|
||||
final_intent="slash",
|
||||
action_result=ToolCallingTurnResult(
|
||||
planned_count=0,
|
||||
executed_count=0,
|
||||
executed_success_count=0,
|
||||
has_unhandled_clause=False,
|
||||
handled=True,
|
||||
),
|
||||
assistant_response_text=text,
|
||||
llm_run=llm_run,
|
||||
)
|
||||
|
||||
|
||||
def test_finalize_applies_pending_turn_llm_when_no_conversational_run() -> None:
|
||||
session = Session()
|
||||
pending = LlmRunInfo(model="claude-sonnet-4-5", provider="anthropic", input_tokens=100)
|
||||
session.terminal.set_pending_turn_llm(pending)
|
||||
recorder = _FakeRecorder()
|
||||
accounting = ShellTurnAccounting(session=session, text="/investigate", recorder=recorder) # type: ignore[arg-type]
|
||||
|
||||
accounting.finalize(_result())
|
||||
|
||||
assert recorder.responses == [("done", pending)]
|
||||
assert recorder.flushed == 1
|
||||
assert session.terminal.pop_pending_turn_llm() is None
|
||||
|
||||
|
||||
def test_finalize_prefers_conversational_run_over_pending() -> None:
|
||||
session = Session()
|
||||
session.terminal.set_pending_turn_llm(LlmRunInfo(model="stale"))
|
||||
conversational = LlmRunInfo(model="fresh")
|
||||
recorder = _FakeRecorder()
|
||||
accounting = ShellTurnAccounting(session=session, text="hi", recorder=recorder) # type: ignore[arg-type]
|
||||
|
||||
accounting.finalize(_result(llm_run=conversational))
|
||||
|
||||
assert recorder.responses[0][1] is conversational
|
||||
# The stale pending run was still consumed so it cannot leak later.
|
||||
assert session.terminal.pop_pending_turn_llm() is None
|
||||
|
||||
|
||||
def test_finalize_sets_structured_error_from_pending_turn_error() -> None:
|
||||
session = Session()
|
||||
session.terminal.set_pending_turn_error("config", "ANTHROPIC_API_KEY not set")
|
||||
recorder = _FakeRecorder()
|
||||
accounting = ShellTurnAccounting(session=session, text="/investigate", recorder=recorder) # type: ignore[arg-type]
|
||||
|
||||
accounting.finalize(_result(text="investigation_failed"))
|
||||
|
||||
assert recorder.errors == [("config", "ANTHROPIC_API_KEY not set")]
|
||||
assert session.terminal.pop_pending_turn_error() is None
|
||||
|
||||
|
||||
def test_finalize_consumes_pending_state_even_without_recorder() -> None:
|
||||
session = Session()
|
||||
session.terminal.set_pending_turn_llm(LlmRunInfo(model="m"))
|
||||
session.terminal.set_pending_turn_error("llm", "boom")
|
||||
accounting = ShellTurnAccounting(session=session, text="hi", recorder=None)
|
||||
|
||||
accounting.finalize(_result())
|
||||
|
||||
assert session.terminal.pop_pending_turn_llm() is None
|
||||
assert session.terminal.pop_pending_turn_error() is None
|
||||
|
||||
|
||||
def test_stage_investigation_turn_telemetry_populates_pending_state() -> None:
|
||||
from surfaces.interactive_shell.command_registry.investigation import (
|
||||
_stage_investigation_turn_telemetry,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.investigation_outcome import InvestigationOutcome
|
||||
|
||||
session = Session()
|
||||
outcome = InvestigationOutcome(
|
||||
status="failed",
|
||||
target="generic",
|
||||
investigation_id="inv-1",
|
||||
error_message="Anthropic authentication failed. Check ANTHROPIC_API_KEY.",
|
||||
failure_category="llm",
|
||||
llm_model="claude-sonnet-4-5",
|
||||
llm_provider="anthropic",
|
||||
llm_input_tokens=500,
|
||||
llm_output_tokens=120,
|
||||
duration_ms=4200,
|
||||
)
|
||||
|
||||
_stage_investigation_turn_telemetry(session, outcome)
|
||||
|
||||
run = session.terminal.pop_pending_turn_llm()
|
||||
assert run is not None
|
||||
assert run.model == "claude-sonnet-4-5"
|
||||
assert run.provider == "anthropic"
|
||||
assert run.input_tokens == 500
|
||||
assert run.output_tokens == 120
|
||||
assert run.latency_ms == 4200
|
||||
assert session.terminal.pop_pending_turn_error() == (
|
||||
"llm",
|
||||
"Anthropic authentication failed. Check ANTHROPIC_API_KEY.",
|
||||
)
|
||||
# Provider-measured tokens also count toward the session totals.
|
||||
assert session.tokens.totals["input"] == 500
|
||||
assert session.tokens.totals["output"] == 120
|
||||
|
||||
|
||||
def test_stage_investigation_turn_telemetry_completed_run_has_no_error() -> None:
|
||||
from surfaces.interactive_shell.command_registry.investigation import (
|
||||
_stage_investigation_turn_telemetry,
|
||||
)
|
||||
from surfaces.interactive_shell.ui.investigation_outcome import InvestigationOutcome
|
||||
|
||||
session = Session()
|
||||
_stage_investigation_turn_telemetry(
|
||||
session,
|
||||
InvestigationOutcome(
|
||||
status="completed",
|
||||
target="generic",
|
||||
investigation_id="inv-2",
|
||||
llm_model="claude-sonnet-4-5",
|
||||
),
|
||||
)
|
||||
|
||||
assert session.terminal.pop_pending_turn_llm() is not None
|
||||
assert session.terminal.pop_pending_turn_error() is None
|
||||
Reference in New Issue
Block a user