4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
967 lines
35 KiB
Python
967 lines
35 KiB
Python
"""Tests for SessionStore: incremental writes (open_session, append_turn, flush, load_recent)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from core.agent_harness.session import (
|
|
JsonlSessionRepo,
|
|
JsonlSessionStorage,
|
|
)
|
|
from core.agent_harness.session.persistence.paths import sessions_dir as _sessions_dir
|
|
from surfaces.interactive_shell.session import (
|
|
Session,
|
|
)
|
|
|
|
|
|
class _SessionStoreFacade(JsonlSessionStorage, JsonlSessionRepo):
|
|
"""Test facade exposing both the storage and repo APIs on one object."""
|
|
|
|
|
|
SessionStore = _SessionStoreFacade()
|
|
|
|
# ── helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_session() -> Session:
|
|
return Session()
|
|
|
|
|
|
def _read_lines(path: Path) -> list[dict]:
|
|
return [json.loads(line) for line in path.read_text().splitlines() if line.strip()]
|
|
|
|
|
|
def _turn_stubs(records: list[dict]) -> list[dict]:
|
|
return [
|
|
record
|
|
for record in records
|
|
if record.get("type") == "custom_message" and record.get("custom_type") == "turn_stub"
|
|
]
|
|
|
|
|
|
def _write_v2_session(path: Path, sid: str, *, started_at: str, text: str = "hi") -> None:
|
|
path.write_text(
|
|
"\n".join(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"type": "session",
|
|
"version": 2,
|
|
"id": sid,
|
|
"created_at": started_at,
|
|
"cwd": "",
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"id": "entry1",
|
|
"parent_id": None,
|
|
"timestamp": started_at,
|
|
"type": "custom_message",
|
|
"custom_type": "turn_stub",
|
|
"kind": "chat",
|
|
"text": text,
|
|
"display": False,
|
|
}
|
|
),
|
|
]
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
|
|
def _patch_dir(tmp_path: Path):
|
|
return patch("core.agent_harness.session.persistence.paths.sessions_dir", return_value=tmp_path)
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Redirect OpenSRE's home to a real temp dir so SessionStore reads/writes real files.
|
|
|
|
No mocking: ``_sessions_dir()`` resolves the real ``OPENSRE_HOME_DIR`` constant
|
|
on every call, so pointing it at a temp directory exercises the genuine
|
|
filesystem path end to end.
|
|
"""
|
|
monkeypatch.setattr("config.constants.OPENSRE_HOME_DIR", tmp_path)
|
|
return tmp_path
|
|
|
|
|
|
# ── open_session ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_open_session_creates_file_with_session_start(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
|
|
files = list(tmp_path.glob("*.jsonl"))
|
|
assert len(files) == 1
|
|
records = _read_lines(files[0])
|
|
assert records[0]["type"] == "session"
|
|
assert records[0]["version"] == 2
|
|
assert records[0]["id"] == session.session_id
|
|
|
|
|
|
def test_open_session_uses_session_id_as_filename(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
assert (tmp_path / f"{session.session_id}.jsonl").exists()
|
|
|
|
|
|
def test_open_session_never_raises_on_bad_path() -> None:
|
|
session = _make_session()
|
|
with patch(
|
|
"core.agent_harness.session.persistence.paths.sessions_dir",
|
|
return_value=Path("/nonexistent/cannot/write"),
|
|
):
|
|
SessionStore.open_session(session) # must not raise
|
|
|
|
|
|
# ── append_turn ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_append_turn_adds_record_to_existing_file(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hello world")
|
|
SessionStore.append_turn(session, "alert", "HighCPU on prod")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
turns = _turn_stubs(records)
|
|
assert len(turns) == 2
|
|
assert turns[0]["kind"] == "chat"
|
|
assert turns[0]["text"] == "hello world"
|
|
assert turns[1]["kind"] == "alert"
|
|
assert turns[1]["text"] == "HighCPU on prod"
|
|
|
|
|
|
def test_append_turn_stores_full_text_without_truncation(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
long_text = "x" * 500
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", long_text)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
turn = next(r for r in records if r["type"] == "custom_message")
|
|
assert len(turn["text"]) == 500
|
|
|
|
|
|
def test_append_turn_noop_when_file_missing(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
# Do NOT call open_session — file doesn't exist
|
|
SessionStore.append_turn(session, "chat", "hello")
|
|
assert not list(tmp_path.glob("*.jsonl")), "no file should be created"
|
|
|
|
|
|
# ── append_turn_detail ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_append_turn_detail_writes_full_prompt_and_response(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn_detail(
|
|
session.session_id,
|
|
"chat",
|
|
"how do I debug high CPU?",
|
|
response="Root cause is a memory leak.",
|
|
turn_id="abc-123",
|
|
model="claude-3-5",
|
|
provider="anthropic",
|
|
latency_ms=1500,
|
|
)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
messages = [r for r in records if r["type"] == "message"]
|
|
assert [(m["role"], m["content"]) for m in messages] == [
|
|
("user", "how do I debug high CPU?"),
|
|
("assistant", "Root cause is a memory leak."),
|
|
]
|
|
assert messages[0]["metadata"]["kind"] == "chat"
|
|
assert messages[0]["metadata"]["turn_id"] == "abc-123"
|
|
assert messages[0]["metadata"]["model"] == "claude-3-5"
|
|
assert messages[0]["metadata"]["provider"] == "anthropic"
|
|
assert messages[0]["metadata"]["latency_ms"] == 1500
|
|
|
|
|
|
def test_append_turn_detail_stores_system_prompt_metadata(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn_detail(
|
|
session.session_id,
|
|
"chat",
|
|
"question",
|
|
response="answer",
|
|
system_prompt="assistant system block",
|
|
)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
messages = [r for r in records if r["type"] == "message"]
|
|
assert messages[0]["metadata"]["system_prompt"] == "assistant system block"
|
|
|
|
|
|
def test_append_turn_detail_omits_none_fields(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn_detail(session.session_id, "chat", "hi")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
detail = next(r for r in records if r["type"] == "message")
|
|
assert detail["role"] == "user"
|
|
assert detail["content"] == "hi"
|
|
assert "turn_id" not in detail["metadata"]
|
|
assert "model" not in detail["metadata"]
|
|
|
|
|
|
def test_append_turn_detail_noop_when_file_missing(tmp_path: Path) -> None:
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.append_turn_detail("nonexistent-id", "chat", "hi")
|
|
assert not list(tmp_path.glob("*.jsonl"))
|
|
|
|
|
|
# ── append_tool_call ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_append_tool_call_writes_record(tmp_home: Path) -> None:
|
|
session = _make_session()
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_tool_call(
|
|
session.session_id,
|
|
tool="call_posthog_tool",
|
|
arguments={"tool": "execute-sql", "args": {"query": "select 1"}},
|
|
result='{"rows": []}',
|
|
ok=True,
|
|
source="posthog_mcp",
|
|
)
|
|
|
|
records = _read_lines(_sessions_dir() / f"{session.session_id}.jsonl")
|
|
call = next(r for r in records if r["type"] == "tool_call")
|
|
result = next(r for r in records if r["type"] == "tool_result")
|
|
assert call["tool"] == "call_posthog_tool"
|
|
assert call["arguments"] == {"tool": "execute-sql", "args": {"query": "select 1"}}
|
|
assert result["content"] == '{"rows": []}'
|
|
assert result["ok"] is True
|
|
assert call["source"] == "posthog_mcp"
|
|
assert "timestamp" in call
|
|
|
|
|
|
def test_append_tool_call_omits_source_when_none(tmp_home: Path) -> None:
|
|
session = _make_session()
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_tool_call(
|
|
session.session_id,
|
|
tool="list_posthog_tools",
|
|
arguments={},
|
|
result="error: boom",
|
|
ok=False,
|
|
)
|
|
|
|
records = _read_lines(_sessions_dir() / f"{session.session_id}.jsonl")
|
|
result = next(r for r in records if r["type"] == "tool_result")
|
|
assert result["ok"] is False
|
|
call = next(r for r in records if r["type"] == "tool_call")
|
|
assert "source" not in call
|
|
|
|
|
|
def test_append_tool_call_noop_when_file_missing(tmp_home: Path) -> None:
|
|
SessionStore.append_tool_call("nonexistent-id", tool="t", arguments={}, result="r", ok=True)
|
|
assert not list(_sessions_dir().glob("*.jsonl"))
|
|
|
|
|
|
def test_append_tool_call_reopens_finalized_session(tmp_home: Path) -> None:
|
|
session = _make_session()
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "cli_agent", "events for davincios in posthog")
|
|
SessionStore.flush(session)
|
|
# A late tool-call write (e.g. background gather) must reopen the file.
|
|
SessionStore.append_tool_call(
|
|
session.session_id, tool="call_posthog_tool", arguments={}, result="{}", ok=True
|
|
)
|
|
|
|
records = _read_lines(_sessions_dir() / f"{session.session_id}.jsonl")
|
|
assert any(r["type"] == "tool_call" for r in records)
|
|
|
|
|
|
# ── flush ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_flush_writes_session_end(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "q1")
|
|
SessionStore.append_turn(session, "alert", "alert1")
|
|
SessionStore.flush(session)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
leaf = records[-1]
|
|
assert leaf["type"] == "leaf"
|
|
assert leaf["total_turns"] == 2
|
|
assert leaf["chat_turns"] == 1
|
|
assert leaf["investigation_turns"] == 1
|
|
|
|
|
|
def test_flush_counts_cli_agent_turns_as_chat(tmp_path: Path) -> None:
|
|
"""Chat turns recorded as kind='cli_agent' must count as chat_turns."""
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "cli_agent", "why is redis slow?")
|
|
SessionStore.append_turn(session, "chat", "how do I use /resume?")
|
|
SessionStore.append_turn(session, "follow_up", "what else?")
|
|
SessionStore.flush(session)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
leaf = records[-1]
|
|
assert leaf["chat_turns"] == 3
|
|
assert leaf["investigation_turns"] == 0
|
|
|
|
|
|
def test_flush_writes_conversation_snapshot_when_messages_present(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
session.agent.messages = [("user", "hello"), ("assistant", "hi there")]
|
|
session.accumulated_context = {"service": "api", "cluster": "prod"}
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hello")
|
|
SessionStore.flush(session)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
messages = [r for r in records if r["type"] == "message"]
|
|
context = next(
|
|
r
|
|
for r in records
|
|
if r.get("type") == "custom_message" and r.get("custom_type") == "accumulated_context"
|
|
)
|
|
assert [(m["role"], m["content"]) for m in messages] == [
|
|
("user", "hello"),
|
|
("assistant", "hi there"),
|
|
]
|
|
assert context["content"] == {"service": "api", "cluster": "prod"}
|
|
# persisted branch entries must come before leaf
|
|
types = [r["type"] for r in records]
|
|
assert types.index("message") < types.index("leaf")
|
|
|
|
|
|
def test_flush_skips_snapshot_when_no_messages(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
assert not any(r["type"] == "message" for r in records)
|
|
|
|
|
|
def test_flush_deletes_file_when_no_turns(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.flush(session)
|
|
|
|
assert not (tmp_path / f"{session.session_id}.jsonl").exists()
|
|
|
|
|
|
def test_flush_keeps_file_when_only_turn_details(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
# turn_detail only (no stub) — should NOT delete the file
|
|
SessionStore.append_turn_detail(session.session_id, "chat", "hello", response="hi")
|
|
SessionStore.flush(session)
|
|
|
|
assert (tmp_path / f"{session.session_id}.jsonl").exists()
|
|
|
|
|
|
def test_flush_noop_when_file_missing(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.flush(session) # no open_session called — must not raise
|
|
|
|
|
|
def test_flush_is_idempotent(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
SessionStore.flush(session) # second call must not append another session_end
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
leaf_records = [r for r in records if r["type"] == "leaf"]
|
|
assert len(leaf_records) == 1, "flush() must be idempotent — only one leaf"
|
|
|
|
|
|
def test_append_turn_reopens_finalized_session(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "hello")
|
|
SessionStore.flush(session)
|
|
session.record("slash", "/status")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
types = [r["type"] for r in records]
|
|
assert types.count("leaf") == 1
|
|
assert records[-1]["type"] == "custom_message"
|
|
assert records[-1]["kind"] == "slash"
|
|
assert records[-1]["text"] == "/status"
|
|
|
|
|
|
def test_reopen_session_strips_trailing_end_and_snapshot(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "hello")
|
|
SessionStore.flush(session)
|
|
SessionStore.reopen_session(session.session_id)
|
|
session.record("chat", "continued")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
types = [r["type"] for r in records]
|
|
assert types.count("leaf") == 1
|
|
assert "conversation_snapshot" not in types
|
|
assert types[-1] == "custom_message"
|
|
assert records[-1]["text"] == "continued"
|
|
|
|
|
|
def test_reopen_session_noop_for_open_session(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "hello")
|
|
SessionStore.reopen_session(session.session_id)
|
|
session.record("chat", "still open")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
assert records[-1]["text"] == "still open"
|
|
assert all(r["type"] != "leaf" for r in records)
|
|
|
|
|
|
# ── session.record() wiring ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_session_record_calls_append_turn(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "what's wrong with prod?")
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
turns = _turn_stubs(records)
|
|
assert len(turns) == 1
|
|
assert turns[0]["kind"] == "chat"
|
|
assert turns[0]["text"] == "what's wrong with prod?"
|
|
|
|
|
|
# ── load_recent ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_recent_returns_empty_when_no_dir(tmp_path: Path) -> None:
|
|
with _patch_dir(tmp_path / "missing"):
|
|
result = SessionStore.load_recent()
|
|
assert result == []
|
|
|
|
|
|
def test_load_recent_counts_turns_for_in_progress_session(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "cli_agent", "hi")
|
|
SessionStore.append_turn(session, "chat", "follow-up")
|
|
SessionStore.append_turn(session, "alert", "OOM")
|
|
# No flush — session still in progress
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["total_turns"] == 3
|
|
assert results[0]["chat_turns"] == 2
|
|
assert results[0]["investigation_turns"] == 1
|
|
assert results[0]["duration_secs"] is None
|
|
assert results[0]["is_ended"] is False
|
|
|
|
|
|
def test_load_recent_uses_session_end_stats_when_available(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["is_ended"] is True
|
|
assert results[0]["total_turns"] == 1
|
|
assert results[0]["duration_secs"] is not None
|
|
|
|
|
|
def test_load_recent_reports_has_snapshot_true(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
session.agent.messages = [("user", "hi"), ("assistant", "hello")]
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["has_snapshot"] is True
|
|
|
|
|
|
def test_load_recent_reports_has_snapshot_false_without_conversation(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["has_snapshot"] is False
|
|
|
|
|
|
def test_load_recent_returns_newest_first(tmp_path: Path) -> None:
|
|
for started in ["2024-01-01T10:00:00+00:00", "2024-01-02T10:00:00+00:00"]:
|
|
sid = str(uuid.uuid4())
|
|
_write_v2_session(tmp_path / f"{sid}.jsonl", sid, started_at=started)
|
|
|
|
with _patch_dir(tmp_path):
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["started_at"] > results[1]["started_at"]
|
|
|
|
|
|
def test_load_recent_skips_malformed_files(tmp_path: Path) -> None:
|
|
(tmp_path / "bad.jsonl").write_text("not json\n")
|
|
(tmp_path / "empty.jsonl").write_text("")
|
|
|
|
sid = str(uuid.uuid4())
|
|
_write_v2_session(
|
|
tmp_path / f"{sid}.jsonl",
|
|
sid,
|
|
started_at="2024-01-01T10:00:00+00:00",
|
|
text="ok",
|
|
)
|
|
|
|
with _patch_dir(tmp_path):
|
|
results = SessionStore.load_recent()
|
|
|
|
assert len(results) == 1
|
|
assert results[0]["session_id"] == sid
|
|
|
|
|
|
def test_load_recent_respects_n_limit(tmp_path: Path) -> None:
|
|
for _ in range(5):
|
|
sid = str(uuid.uuid4())
|
|
_write_v2_session(
|
|
tmp_path / f"{sid}.jsonl",
|
|
sid,
|
|
started_at="2024-01-01T10:00:00+00:00",
|
|
)
|
|
|
|
with _patch_dir(tmp_path):
|
|
assert len(SessionStore.load_recent(n=3)) == 3
|
|
|
|
|
|
# ── load_session ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_session_returns_none_for_missing_prefix(tmp_path: Path) -> None:
|
|
with _patch_dir(tmp_path):
|
|
assert SessionStore.load_session("nonexistent") is None
|
|
|
|
|
|
def test_load_session_returns_none_when_no_dir(tmp_path: Path) -> None:
|
|
with _patch_dir(tmp_path / "missing"):
|
|
assert SessionStore.load_session("abc") is None
|
|
|
|
|
|
def test_load_session_restores_from_conversation_snapshot(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
session.agent.messages = [("user", "how is prod?"), ("assistant", "prod is healthy")]
|
|
session.accumulated_context = {"service": "api"}
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "how is prod?")
|
|
SessionStore.flush(session)
|
|
|
|
data = SessionStore.load_session(session.session_id[:8])
|
|
|
|
assert data is not None
|
|
assert data["has_snapshot"] is False
|
|
assert data["cli_agent_messages"] == [
|
|
("user", "how is prod?"),
|
|
("assistant", "prod is healthy"),
|
|
]
|
|
assert data["accumulated_context"] == {"service": "api"}
|
|
|
|
|
|
def test_load_session_fallback_to_turn_details_when_no_snapshot(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn_detail(
|
|
session.session_id, "chat", "debug high CPU", response="It's a leak"
|
|
)
|
|
# Flush without cli_agent_messages — no snapshot written
|
|
SessionStore.flush(session)
|
|
|
|
data = SessionStore.load_session(session.session_id[:8])
|
|
|
|
assert data is not None
|
|
assert data["has_snapshot"] is False
|
|
messages = data["cli_agent_messages"]
|
|
assert ("user", "debug high CPU") in messages
|
|
assert ("assistant", "It's a leak") in messages
|
|
|
|
|
|
def test_load_session_ambiguous_prefix_returns_none(tmp_path: Path) -> None:
|
|
# Two sessions sharing the same prefix
|
|
for _ in range(2):
|
|
sid = "aaaabbbb" + str(uuid.uuid4())[8:]
|
|
_write_v2_session(
|
|
tmp_path / f"{sid}.jsonl",
|
|
sid,
|
|
started_at="2024-01-01T10:00:00+00:00",
|
|
)
|
|
with _patch_dir(tmp_path):
|
|
result = SessionStore.load_session("aaaa")
|
|
assert result is None
|
|
|
|
|
|
def test_load_session_includes_history_and_turn_details(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hello")
|
|
SessionStore.append_turn_detail(session.session_id, "chat", "hello", response="hi")
|
|
SessionStore.flush(session)
|
|
|
|
data = SessionStore.load_session(session.session_id)
|
|
|
|
assert data is not None
|
|
assert len(data["history"]) == 1
|
|
assert data["history"][0]["kind"] == "chat"
|
|
assert len(data["turn_details"]) == 1
|
|
assert data["turn_details"][0]["response"] == "hi"
|
|
|
|
|
|
# ── session name derivation ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_recent_derives_name_from_turn_detail(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "why is redis slow?")
|
|
SessionStore.append_turn_detail(
|
|
session.session_id, "chat", "why is redis slow?", response="It's a memory issue"
|
|
)
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["name"] == "why is redis slow?"
|
|
|
|
|
|
def test_load_recent_derives_name_from_cli_agent_turn(tmp_path: Path) -> None:
|
|
"""Real chat turns recorded as kind='cli_agent' are reconstructed."""
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "cli_agent", "debug the OOM killer on prod")
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["name"] == "debug the OOM killer on prod"
|
|
|
|
|
|
def test_load_recent_derives_name_from_turn_stub_when_no_detail(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "alert", "HighCPU on prod-api-1")
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["name"] == "HighCPU on prod-api-1"
|
|
|
|
|
|
def test_load_recent_name_truncated_at_50_chars(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
long_prompt = "a" * 60
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", long_prompt)
|
|
|
|
results = SessionStore.load_recent()
|
|
|
|
assert results[0]["name"] == "a" * 50 + "…"
|
|
|
|
|
|
def test_load_recent_name_empty_for_slash_only_session(tmp_path: Path) -> None:
|
|
sid = str(uuid.uuid4())
|
|
(tmp_path / f"{sid}.jsonl").write_text(
|
|
"\n".join(
|
|
[
|
|
json.dumps(
|
|
{
|
|
"type": "session",
|
|
"version": 2,
|
|
"id": sid,
|
|
"created_at": "2024-01-01T10:00:00+00:00",
|
|
"cwd": "",
|
|
}
|
|
),
|
|
json.dumps(
|
|
{
|
|
"id": "entry1",
|
|
"parent_id": None,
|
|
"timestamp": "2024-01-01T10:00:01+00:00",
|
|
"type": "custom_message",
|
|
"custom_type": "turn_stub",
|
|
"kind": "slash",
|
|
"text": "/status",
|
|
"display": False,
|
|
}
|
|
),
|
|
]
|
|
)
|
|
+ "\n"
|
|
)
|
|
with _patch_dir(tmp_path):
|
|
results = SessionStore.load_recent()
|
|
assert results[0]["name"] == ""
|
|
|
|
|
|
def test_load_session_includes_name(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "debug the OOM killer")
|
|
SessionStore.flush(session)
|
|
|
|
data = SessionStore.load_session(session.session_id[:8])
|
|
|
|
assert data is not None
|
|
assert data["name"] == "debug the OOM killer"
|
|
|
|
|
|
def test_count_prefix_matches_returns_correct_count(tmp_path: Path) -> None:
|
|
for _ in range(3):
|
|
sid = str(uuid.uuid4())
|
|
_write_v2_session(
|
|
tmp_path / f"{sid}.jsonl",
|
|
sid,
|
|
started_at="2024-01-01T10:00:00+00:00",
|
|
)
|
|
with _patch_dir(tmp_path):
|
|
# Full UUID prefix matches exactly one
|
|
first_sid = list(tmp_path.glob("*.jsonl"))[0].stem
|
|
assert SessionStore.count_prefix_matches(first_sid[:8]) == 1
|
|
# Very short prefix may match multiple — no assertion on count, just that it doesn't raise
|
|
count = SessionStore.count_prefix_matches("")
|
|
assert count == 3
|
|
|
|
|
|
def test_count_prefix_matches_returns_zero_for_missing_dir(tmp_path: Path) -> None:
|
|
with _patch_dir(tmp_path / "missing"):
|
|
assert SessionStore.count_prefix_matches("abc") == 0
|
|
|
|
|
|
def test_load_session_matches_full_id(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_turn(session, "chat", "hi")
|
|
SessionStore.flush(session)
|
|
|
|
data = SessionStore.load_session(session.session_id)
|
|
|
|
assert data is not None
|
|
assert data["session_id"] == session.session_id
|
|
|
|
|
|
# ── flush resilience ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_flush_writes_session_end_even_when_snapshot_serialization_fails(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""P1: a snapshot serialization failure must not prevent session_end from being written."""
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "hello")
|
|
# Inject a non-JSON-serializable value into accumulated_context so
|
|
# json.dumps(snapshot) raises TypeError inside the inner suppress block.
|
|
session.accumulated_context["bad"] = object() # type: ignore[assignment]
|
|
|
|
SessionStore.flush(session)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
types = [r["type"] for r in records]
|
|
# context entry may degrade through default=str, but leaf must be present.
|
|
assert "leaf" in types
|
|
assert records[-1]["type"] == "leaf"
|
|
|
|
|
|
# ── /new lifecycle ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_new_closes_old_session_and_opens_new(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
session.record("chat", "pre-new question")
|
|
sid1 = session.session_id
|
|
|
|
# Simulate /new (flush → clear → open_session)
|
|
SessionStore.flush(session)
|
|
session.clear()
|
|
SessionStore.open_session(session)
|
|
sid2 = session.session_id
|
|
|
|
session.record("chat", "post-new question")
|
|
|
|
assert sid1 != sid2
|
|
# Old session file has a leaf marker
|
|
old_records = _read_lines(tmp_path / f"{sid1}.jsonl")
|
|
assert old_records[-1]["type"] == "leaf"
|
|
# New session file exists with turn but no leaf yet
|
|
new_records = _read_lines(tmp_path / f"{sid2}.jsonl")
|
|
assert new_records[0]["type"] == "session"
|
|
assert any(r["type"] == "custom_message" for r in new_records)
|
|
assert new_records[-1]["type"] != "leaf"
|
|
|
|
|
|
# ── Session field behaviour ───────────────────────────────────────────────
|
|
|
|
|
|
def test_repl_session_has_stable_session_id() -> None:
|
|
s = _make_session()
|
|
assert isinstance(s.session_id, str) and len(s.session_id) > 0
|
|
assert s.started_at <= time.time()
|
|
|
|
|
|
def test_session_rotates_id_on_clear() -> None:
|
|
s = _make_session()
|
|
original_id = s.session_id
|
|
s.history.append({"type": "chat", "text": "hi", "ok": True})
|
|
time.sleep(0.01)
|
|
s.clear()
|
|
assert s.session_id != original_id
|
|
assert s.started_at <= time.time()
|
|
|
|
|
|
# ── investigation_result / RCA history ───────────────────────────────────────
|
|
|
|
|
|
def test_append_investigation_result_writes_record(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
state = {
|
|
"root_cause": "connection pool exhausted",
|
|
"problem_md": "## Summary\nPool leak in checkout-api",
|
|
"root_cause_category": "resource",
|
|
"alert_name": "checkout latency",
|
|
}
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
inv_id = SessionStore.append_investigation_result(
|
|
session.session_id,
|
|
state,
|
|
trigger="/investigate generic",
|
|
)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
inv = next(r for r in records if r["type"] == "investigation_result")
|
|
assert inv["investigation_id"] == inv_id
|
|
assert inv["root_cause"] == "connection pool exhausted"
|
|
assert "Pool leak" in inv["report"]
|
|
assert inv["trigger"] == "/investigate generic"
|
|
|
|
|
|
def test_append_investigation_result_uses_report_fallback(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
SessionStore.append_investigation_result(
|
|
session.session_id,
|
|
{"root_cause": "api error", "report": "report-only payload"},
|
|
trigger="/investigate generic",
|
|
)
|
|
|
|
records = _read_lines(tmp_path / f"{session.session_id}.jsonl")
|
|
inv = next(r for r in records if r["type"] == "investigation_result")
|
|
assert inv["report"] == "report-only payload"
|
|
|
|
|
|
def test_load_investigation_history_returns_newest_first(tmp_path: Path) -> None:
|
|
session_a = _make_session()
|
|
session_b = Session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session_a)
|
|
SessionStore.append_investigation_result(
|
|
session_a.session_id,
|
|
{"root_cause": "older issue", "problem_md": "old report"},
|
|
trigger="/investigate generic",
|
|
)
|
|
SessionStore.open_session(session_b)
|
|
SessionStore.append_investigation_result(
|
|
session_b.session_id,
|
|
{"root_cause": "newer issue", "problem_md": "new report"},
|
|
trigger="/investigate datadog",
|
|
)
|
|
|
|
history = SessionStore.load_investigation_history()
|
|
|
|
assert len(history) == 2
|
|
assert history[0]["root_cause"] == "newer issue"
|
|
assert history[1]["root_cause"] == "older issue"
|
|
|
|
|
|
def test_load_investigation_by_prefix(tmp_path: Path) -> None:
|
|
session = _make_session()
|
|
with _patch_dir(tmp_path):
|
|
SessionStore.open_session(session)
|
|
inv_id = SessionStore.append_investigation_result(
|
|
session.session_id,
|
|
{"root_cause": "disk full", "problem_md": "report body"},
|
|
trigger="/investigate alert.json",
|
|
)
|
|
loaded = SessionStore.load_investigation(inv_id[:4])
|
|
|
|
assert loaded is not None
|
|
assert loaded["investigation_id"] == inv_id
|
|
assert loaded["root_cause"] == "disk full"
|
|
|
|
|
|
def test_apply_investigation_result_persists_record(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr("config.constants.OPENSRE_HOME_DIR", tmp_path)
|
|
session = Session()
|
|
SessionStore.open_session(session)
|
|
session.apply_investigation_result(
|
|
{"root_cause": "OOM killer", "problem_md": "memory spike"},
|
|
trigger="sample:generic",
|
|
)
|
|
|
|
history = SessionStore.load_investigation_history()
|
|
assert len(history) == 1
|
|
assert history[0]["root_cause"] == "OOM killer"
|
|
assert history[0]["trigger"] == "sample:generic"
|