Files
tracer-cloud--opensre/tests/interactive_shell/sessions/test_resume_scenarios.py
T
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

444 lines
16 KiB
Python

"""End-to-end /resume scenario tests: session identity, JSONL turns, slash recording."""
from __future__ import annotations
import io
import json
import os
from pathlib import Path
from unittest.mock import patch
import pytest
from pydantic import ValidationError
from rich.console import Console
from config.config import (
get_configured_llm_provider,
get_llm_provider_api_key_env,
resolve_llm_settings_verbose,
)
from config.llm_auth.credentials import status as credential_status
from core.agent_harness.session import JsonlSessionStorage
from surfaces.interactive_shell.command_registry import dispatch_slash
from surfaces.interactive_shell.session import Session
SessionStore = JsonlSessionStorage()
def _capture() -> tuple[Console, io.StringIO]:
buf = io.StringIO()
return Console(file=buf, force_terminal=False, highlight=False), buf
def _read_turns(path: Path) -> list[dict]:
if not path.exists():
return []
turns: list[dict] = []
for line in path.read_text(encoding="utf-8").splitlines():
if not line.strip():
continue
rec = json.loads(line)
if rec.get("type") == "custom_message" and rec.get("custom_type") == "turn_stub":
turns.append({"type": "turn", "kind": rec.get("kind"), "text": rec.get("text")})
return turns
def _write_finalized_session(
sessions_dir: Path,
session_id: str,
*,
chat_text: str = "why is redis slow?",
messages: list[tuple[str, str]] | None = None,
context: dict[str, str] | None = None,
) -> Path:
"""Create a closed session file that /resume can load."""
path = sessions_dir / f"{session_id}.jsonl"
msgs = messages or [("user", chat_text), ("assistant", "check connection pool")]
ctx = context or {"service": "redis"}
ids = [f"entry{i}" for i in range(1, 6)]
lines = [
json.dumps(
{
"type": "session",
"version": 2,
"id": session_id,
"created_at": "2026-05-29T10:00:00+00:00",
"cwd": "",
}
),
json.dumps(
{
"id": ids[0],
"parent_id": None,
"timestamp": "2026-05-29T10:00:01+00:00",
"type": "custom_message",
"custom_type": "turn_stub",
"kind": "chat",
"text": chat_text,
"display": False,
}
),
json.dumps(
{
"id": ids[1],
"parent_id": ids[0],
"timestamp": "2026-05-29T10:00:02+00:00",
"type": "message",
"role": "user",
"content": msgs[0][1],
"metadata": {"kind": "chat"},
}
),
json.dumps(
{
"id": ids[2],
"parent_id": ids[1],
"timestamp": "2026-05-29T10:00:03+00:00",
"type": "message",
"role": "assistant",
"content": msgs[1][1],
"metadata": {"kind": "chat"},
}
),
json.dumps(
{
"id": ids[3],
"parent_id": ids[2],
"timestamp": "2026-05-29T10:00:04+00:00",
"type": "custom_message",
"custom_type": "accumulated_context",
"content": ctx,
"display": False,
}
),
json.dumps(
{
"id": ids[4],
"parent_id": ids[3],
"timestamp": "2026-05-29T10:00:05+00:00",
"type": "leaf",
"total_turns": 1,
}
),
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
@pytest.fixture
def isolated_sessions(tmp_path: Path) -> Path:
"""Sessions directory with SessionStore patched for the test."""
directory = tmp_path / "sessions"
directory.mkdir()
with patch("config.constants.OPENSRE_HOME_DIR", tmp_path):
yield directory
def _open_current(session: Session) -> None:
SessionStore.open_session(session)
def _require_live_llm_for_repl_planner() -> None:
explicit_pin = os.environ.get("LLM_PROVIDER", "").strip().lower()
resolution = None
try:
resolution = resolve_llm_settings_verbose()
except ValidationError as exc:
provider = get_configured_llm_provider()
env_var = get_llm_provider_api_key_env(provider)
msg = exc.errors()[0].get("msg", str(exc)) if exc.errors() else str(exc)
hint = f" configured provider={provider!r}"
if env_var is not None:
hint += f", required key={env_var}"
pytest.skip(f"Skipping live REPL planner smoke; missing LLM configuration:{hint}. {msg}")
if resolution is None:
pytest.skip("Skipping live REPL planner smoke; LLM configuration was not resolved.")
if explicit_pin and resolution.resolved_provider != explicit_pin:
pytest.skip(
f"Skipping live REPL planner smoke; LLM_PROVIDER={explicit_pin!r} resolved as "
f"{resolution.resolved_provider!r}."
)
auth = credential_status(resolution.resolved_provider)
if not auth.configured or auth.stale:
pytest.skip(
"Skipping live REPL planner smoke; missing LLM credentials:"
f" provider={resolution.resolved_provider!r}, auth={auth.source}, detail={auth.detail}"
)
env_var = get_llm_provider_api_key_env(resolution.resolved_provider)
if env_var is not None and not os.environ.get(env_var):
pytest.skip(
f"Skipping live REPL planner smoke; {env_var} is not exported to the child REPL."
)
class TestResumeScenarioMatrix:
"""Scenario coverage for /resume session adoption and JSONL persistence."""
def test_scenario_fresh_repl_resumes_prior_session(self, isolated_sessions: Path) -> None:
"""Fresh REPL session resumes a prior session: adopt ID, slash on target only."""
target_id = "aaaa1111-2222-3333-4444-555566667777"
_write_finalized_session(isolated_sessions, target_id, chat_text="why is redis slow?")
session = Session()
current_id = session.session_id
_open_current(session)
console, buf = _capture()
assert dispatch_slash(f"/resume {target_id[:8]}", session, console) is True
assert session.session_id == target_id
assert "resumed session" in buf.getvalue()
current_path = isolated_sessions / f"{current_id}.jsonl"
if current_path.exists():
assert not any(
t.get("kind") == "slash" and "/resume" in t.get("text", "")
for t in _read_turns(current_path)
)
target_turns = _read_turns(isolated_sessions / f"{target_id}.jsonl")
assert any(
t.get("kind") == "slash" and t.get("text", "").startswith("/resume")
for t in target_turns
)
assert session.agent.messages[0] == ("user", "why is redis slow?")
assert session.accumulated_context == {"service": "redis"}
def test_scenario_post_resume_slash_and_chat_append_to_target(
self,
isolated_sessions: Path,
) -> None:
"""After /resume, further slash commands and chat turns land on the same file."""
target_id = "bbbb2222-3333-4444-5555-666677778888"
_write_finalized_session(isolated_sessions, target_id)
session = Session()
_open_current(session)
console, _ = _capture()
dispatch_slash(f"/resume {target_id[:8]}", session, console)
dispatch_slash("/status", session, console)
session.record("chat", "follow-up question after resume")
target_path = isolated_sessions / f"{target_id}.jsonl"
kinds = [t["kind"] for t in _read_turns(target_path)]
assert "slash" in kinds
assert kinds.count("slash") >= 2
assert "chat" in kinds
assert "leaf" in target_path.read_text(encoding="utf-8")
def test_scenario_empty_starter_session_removed_on_resume(
self,
isolated_sessions: Path,
) -> None:
"""A brand-new REPL with no turns should not leave a junk file after /resume."""
target_id = "cccc3333-4444-5555-6666-777788889999"
_write_finalized_session(isolated_sessions, target_id)
session = Session()
starter_id = session.session_id
_open_current(session)
console, _ = _capture()
dispatch_slash(f"/resume {target_id[:8]}", session, console)
starter_path = isolated_sessions / f"{starter_id}.jsonl"
assert not starter_path.exists()
assert session.session_id == target_id
def test_scenario_resume_by_name_substring(self, isolated_sessions: Path) -> None:
target_id = "dddd4444-5555-6666-7777-888899990000"
_write_finalized_session(isolated_sessions, target_id, chat_text="investigate OOM killer")
session = Session()
_open_current(session)
console, buf = _capture()
dispatch_slash("/resume OOM", session, console)
assert session.session_id == target_id
assert "resumed session" in buf.getvalue()
target_turns = _read_turns(isolated_sessions / f"{target_id}.jsonl")
assert any(t.get("text") == "/resume OOM" for t in target_turns)
def test_resume_session_by_prefix_matches_name_substring(
self,
isolated_sessions: Path,
) -> None:
from surfaces.interactive_shell.command_registry.session_cmds.resume import (
resume_session_by_prefix,
)
target_id = "eeee5555-6666-7777-8888-999900001111"
_write_finalized_session(isolated_sessions, target_id, chat_text="investigate OOM killer")
session = Session()
_open_current(session)
console, buf = _capture()
assert resume_session_by_prefix("OOM", session, console)
assert session.session_id == target_id
assert "resumed session" in buf.getvalue()
def test_scenario_resume_not_found_records_on_current(
self,
isolated_sessions: Path,
) -> None:
session = Session()
current_id = session.session_id
_open_current(session)
console, buf = _capture()
dispatch_slash("/resume deadbeef", session, console)
assert session.session_id == current_id
assert "not found" in buf.getvalue()
turns = _read_turns(isolated_sessions / f"{current_id}.jsonl")
assert turns[-1]["kind"] == "slash"
assert turns[-1]["text"] == "/resume deadbeef"
assert session.history[-1]["ok"] is False
def test_scenario_resume_current_session_guard(
self,
isolated_sessions: Path,
) -> None:
session = Session()
_open_current(session)
session.record("chat", "still working here")
console, buf = _capture()
dispatch_slash(f"/resume {session.session_id[:8]}", session, console)
assert "current session" in buf.getvalue()
assert session.session_id # unchanged
turns = _read_turns(isolated_sessions / f"{session.session_id}.jsonl")
assert any(t.get("text", "").startswith("/resume") for t in turns)
def test_scenario_resume_empty_target_does_not_switch(
self,
isolated_sessions: Path,
) -> None:
empty_id = "eeee5555-6666-7777-8888-999900001111"
path = isolated_sessions / f"{empty_id}.jsonl"
path.write_text(
json.dumps(
{
"type": "session",
"version": 2,
"id": empty_id,
"created_at": "2026-05-29T10:00:00+00:00",
"cwd": "",
}
)
+ "\n",
encoding="utf-8",
)
session = Session()
current_id = session.session_id
_open_current(session)
console, buf = _capture()
dispatch_slash(f"/resume {empty_id[:8]}", session, console)
assert session.session_id == current_id
assert "no conversation to resume" in buf.getvalue()
def test_scenario_chain_resume_two_targets(
self,
isolated_sessions: Path,
) -> None:
"""Resume session A, then resume session B — each gets its own /resume slash turn."""
id_a = "ffff6666-7777-8888-9999-000011112222"
id_b = "11117777-8888-9999-0000-111122223333"
_write_finalized_session(isolated_sessions, id_a, chat_text="session A question")
_write_finalized_session(isolated_sessions, id_b, chat_text="session B question")
session = Session()
_open_current(session)
console, _ = _capture()
dispatch_slash(f"/resume {id_a[:8]}", session, console)
assert session.session_id == id_a
dispatch_slash(f"/resume {id_b[:8]}", session, console)
assert session.session_id == id_b
turns_a = _read_turns(isolated_sessions / f"{id_a}.jsonl")
turns_b = _read_turns(isolated_sessions / f"{id_b}.jsonl")
assert any("/resume" in t.get("text", "") for t in turns_a)
assert any("/resume" in t.get("text", "") for t in turns_b)
assert session.agent.messages[0] == ("user", "session B question")
def test_scenario_active_session_with_turns_flushed_without_resume_slash(
self,
isolated_sessions: Path,
) -> None:
"""Switching away from a session that had real turns preserves them without /resume."""
target_id = "22228888-9999-0000-1111-222233334444"
_write_finalized_session(isolated_sessions, target_id)
session = Session()
current_id = session.session_id
_open_current(session)
session.record("chat", "work in progress")
console, _ = _capture()
dispatch_slash(f"/resume {target_id[:8]}", session, console)
old_turns = _read_turns(isolated_sessions / f"{current_id}.jsonl")
assert any(t["kind"] == "chat" for t in old_turns)
assert not any(t.get("kind") == "slash" for t in old_turns)
assert (isolated_sessions / f"{current_id}.jsonl").read_text(encoding="utf-8").find(
'"type": "leaf"'
) != -1
@pytest.mark.integration
@pytest.mark.live_llm
class TestResumeLiveRepl:
"""Live REPL smoke test via ReplDriver with isolated HOME and real planner."""
@pytest.mark.skip(
reason="Flaky live-LLM REPL round-trip (subprocess + 60s waits); resume load "
"logic is covered offline. Run manually via ReplDriver."
)
def test_live_resume_round_trip(self, tmp_path: Path) -> None:
_require_live_llm_for_repl_planner()
from tests.utils.repl_driver import ReplDriver
home = tmp_path / "home"
home.mkdir()
sessions_dir = home / ".opensre" / "sessions"
sessions_dir.mkdir(parents=True)
target_id = "live9999-aaaa-bbbb-cccc-ddddeeeeffff"
_write_finalized_session(sessions_dir, target_id, chat_text="live redis investigation")
with ReplDriver(home=home, startup_wait=10.0) as repl:
if repl.contains("Press Enter to continue"):
repl.send("", wait=3.0)
repl.reset_output()
repl.send("/sessions", wait=1.0)
assert repl.wait_until_contains("live9999", "live redis", timeout=60.0)
repl.reset_output()
repl.send(f"/resume {target_id[:8]}", wait=1.0)
assert repl.wait_until_contains("resumed session", timeout=60.0)
assert repl.wait_until_contains("live redis investigation", timeout=10.0)
repl.reset_output()
repl.send("/status", wait=1.0)
assert repl.wait_until_contains("interactions", timeout=60.0)
target_path = sessions_dir / f"{target_id}.jsonl"
assert target_path.exists()
turns = _read_turns(target_path)
assert any(t.get("kind") == "slash" and "/resume" in t.get("text", "") for t in turns)
assert any(t.get("kind") == "slash" and "/status" in t.get("text", "") for t in turns)