Returning to chat.hermes.bstein.dev after a Keycloak logout/login showed
"This session is unavailable to this account. Start a new chat." even
though the session was intact and owned by the same subject.
The banner comes from the continuity fallback the router injects into
every chat page. It polled `/api/sessions/<id>` and
`/api/sessions/<id>/messages` — routes that belong to the Hermes agent
dashboard (added by scripts/patch_web_session_activity.py, applied only
in agent-deployment.yaml). The router proxies browser traffic to the
tenant Hermes WebUI instead, whose only session read is
`GET /api/session?session_id=<id>`; the dashboard paths are unrouted
there, so server.py answered its generic 404 for every poll and the
fallback reported a false ownership failure.
The script runs only on a full document load of `/session/<id>`, which is
exactly what the OIDC round-trip produces when oauth2-proxy returns the
browser to `rd=/session/<id>` — hence the "only after relogin" symptom.
Poll the WebUI contract instead, and let its own answers decide what the
banner claims: 409 `session_profile_mismatch` is the single response that
means the session is outside this account's active scope, 404 now means
the conversation is no longer stored, and 401/403 still re-enter OIDC.
The steady-state poll drops to one request and backs off to 3s/15s now
that it reaches a real endpoint on the tenant Raspberry Pi.
`boundSessionSnapshot` follows the same move: it caps the WebUI envelope
`{"session": {..., "messages": [...]}}`, relaying every other session key
verbatim rather than re-serializing a fixed struct that would silently
drop metadata the banner depends on.
Isolation is unchanged and now covered: the router still resolves the
slot from the salted Keycloak subject, overwrites any client-supplied
X-Hermes-Tenant-Identity, and forwards only the two tenant cookies.
Tests: relogin keeps a stable slot and resolves the durable session; a
second subject replaying the owner's session id, WebUI cookie and a
forged tenant header gets 404 from its own backend and never reaches the
owner's; the legacy dashboard paths are pinned as permanent 404s against
a stub of the deployed WebUI dispatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
540 lines
21 KiB
Python
540 lines
21 KiB
Python
"""Session lineage and continuity contracts for Hermes chat."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_chat_support import (
|
|
HERMES,
|
|
_documents,
|
|
)
|
|
|
|
|
|
def _containers(path: Path) -> list[dict]:
|
|
"""Return every container and init container in one workload document."""
|
|
spec = _documents(path)[0]["spec"]["template"]["spec"]
|
|
return [*spec.get("initContainers", []), *spec.get("containers", [])]
|
|
|
|
|
|
def _env(container: dict) -> dict[str, str]:
|
|
return {
|
|
entry["name"]: entry.get("value", "")
|
|
for entry in container.get("env", [])
|
|
if isinstance(entry, dict) and "name" in entry
|
|
}
|
|
|
|
|
|
def test_chat_continuity_polls_the_route_its_own_backend_serves():
|
|
"""The injected fallback must speak the tenant WebUI session contract."""
|
|
fallback = (HERMES / "router" / "session_continuity.go").read_text(encoding="utf-8")
|
|
snapshot = (HERMES / "router" / "session_snapshot.go").read_text(encoding="utf-8")
|
|
script = fallback.split("const sessionContinuityJS = `", 1)[1].rsplit("`", 1)[0]
|
|
|
|
assert "'/api/session?session_id=' + encodeURIComponent(sessionId)" in script
|
|
assert "&messages=1&msg_limit=24" in script
|
|
assert "hermes_fallback=1" in script
|
|
# A 409 is the only answer that means "not in this account's active scope".
|
|
assert "session_profile_mismatch" in script
|
|
assert 'sessionFallbackPath = "/api/session"' in fallback
|
|
assert "request.URL.Path != sessionFallbackPath" in snapshot
|
|
|
|
# /api/sessions/<id>[/messages] is the Hermes agent dashboard contract. The
|
|
# chat tenants never route it, so polling it was a permanent 404 that
|
|
# reported a false ownership failure after every full page load.
|
|
assert "/api/sessions/" not in script
|
|
|
|
|
|
def test_dashboard_session_route_never_backs_the_chat_tenants():
|
|
"""Only the agent dashboard gains /api/sessions/{id}/messages."""
|
|
activity_patch = "patch_web_session_activity.py"
|
|
marker = '@app.get("/api/sessions/{session_id}/messages")'
|
|
assert marker in (HERMES / "scripts" / activity_patch).read_text(encoding="utf-8")
|
|
|
|
agent = _containers(HERMES / "agent-deployment.yaml")
|
|
assert any(
|
|
activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", [])))
|
|
for container in agent
|
|
)
|
|
|
|
tenants = _containers(HERMES / "chat-statefulset.yaml")
|
|
assert not any(
|
|
activity_patch in " ".join(map(str, container.get("command", []) + container.get("args", [])))
|
|
for container in tenants
|
|
)
|
|
|
|
# The router proxies browser traffic to this WebUI container, and asserts
|
|
# the tenant identity through the header the WebUI is told to trust.
|
|
webui = next(container for container in tenants if container["name"] == "webui")
|
|
router = (HERMES / "router" / "main.go").read_text(encoding="utf-8")
|
|
header = _env(webui)["HERMES_WEBUI_TRUSTED_AUTH_HEADER"]
|
|
assert f'trustedTenantHeader = "{header}"' in router
|
|
|
|
|
|
def test_codex_native_health_overrides_historical_router_errors(
|
|
tmp_path: Path, monkeypatch
|
|
):
|
|
"""Fresh first-party health is authoritative over old Switchyard probes."""
|
|
plugin_path = HERMES / "plugins" / "auto-router" / "provider_status.py"
|
|
spec = importlib.util.spec_from_file_location("hermes_provider_status", plugin_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
health_path = tmp_path / "codex.json"
|
|
health_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"state": "available",
|
|
"authenticated": True,
|
|
"transport": "codex-chatgpt-subscription",
|
|
}
|
|
)
|
|
)
|
|
monkeypatch.setattr(module, "CODEX_HEALTH_PATH", health_path)
|
|
monkeypatch.setattr(module, "CLAUDE_HEALTH_PATH", tmp_path / "missing.json")
|
|
monkeypatch.setattr(
|
|
module,
|
|
"_get_json",
|
|
lambda url: {"status": "ok"}
|
|
if url.endswith("/health")
|
|
else {
|
|
"models": {
|
|
"route/codex/terra/medium": {
|
|
"calls": 1,
|
|
"errors": 99,
|
|
"total_tokens": 12,
|
|
}
|
|
}
|
|
},
|
|
)
|
|
monkeypatch.setattr(module, "_codex_account", lambda: {})
|
|
monkeypatch.setattr(module, "_claude_account", lambda: {})
|
|
|
|
codex = module.provider_status_payload()["providers"]["codex"]
|
|
|
|
assert codex["errors"] == 99
|
|
assert codex["state"] == "available"
|
|
assert codex["native_health"]["transport"] == "codex-chatgpt-subscription"
|
|
|
|
|
|
def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
|
|
"""API-created workers must persist the originating Hermes session."""
|
|
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
|
|
spec = importlib.util.spec_from_file_location("patch_api_sessions", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
source = tmp_path / "api_server.py"
|
|
destination = tmp_path / "patched.py"
|
|
source.write_text(
|
|
"prefix\n"
|
|
+ module.BEFORE
|
|
+ "middle\n"
|
|
+ module.RUNS_BEFORE
|
|
+ "run body\n"
|
|
+ module.RUN_CLOSE_BEFORE
|
|
+ module.RESPONSES_SESSION_BEFORE
|
|
+ module.EVENT_CALLBACK_SIGNATURE_BEFORE
|
|
+ "callback docstring and push helper\n"
|
|
+ module.EVENT_CALLBACK_BODY_BEFORE
|
|
+ "tool start body\n"
|
|
+ module.EVENT_CALLBACK_END_BEFORE
|
|
+ module.EVENT_CALLBACK_CALL_BEFORE
|
|
+ module.RUN_SWEEP_BEFORE
|
|
+ "suffix\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
module.patch(source, destination)
|
|
patched = destination.read_text(encoding="utf-8")
|
|
|
|
assert "X-Hermes-Parent-Session-Id" in patched
|
|
assert "parent_session_id=parent_session_id" in patched
|
|
assert "Parent session not found" in patched
|
|
assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES" in patched
|
|
assert "user_message.startswith(default_prefixes)" in patched
|
|
assert "session_parent_conflict" in patched
|
|
assert "X-Hermes-Conversation-Platform" in patched
|
|
assert "X-Hermes-Conversation-Title" in patched
|
|
assert 'conversation_platform != "telegram"' in patched
|
|
assert "db.record_gateway_session_peer(" in patched
|
|
assert 'display_name="Telegram"' in patched
|
|
assert "db.reopen_session(session_id)" in patched
|
|
assert 'db.end_session(session_id, f"api_run_{terminal_status}")' in patched
|
|
assert "def _record_run_activity(" in patched
|
|
assert '"_thinking": "Hermes is reasoning"' in patched
|
|
assert '"run.started": "Worker started"' in patched
|
|
assert '"run.completed": "Worker completed"' in patched
|
|
assert '"reasoning.available": "Hermes finished a reasoning step"' in patched
|
|
assert '"subagent.progress": "Nested worker progress"' in patched
|
|
assert "redact_sensitive_text" in patched
|
|
assert 'getattr(os, "O_NOFOLLOW", 0)' in patched
|
|
assert "os.fchmod(fd, 0o600)" in patched
|
|
assert "session_id=session_id" in patched
|
|
assert 'self._record_run_activity(session_id, "run.started")' in patched
|
|
assert "detail = tool_name if event_type in {" in patched
|
|
assert 'if event_type == "subagent.tool"' in patched
|
|
assert "_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0" in patched
|
|
assert "heartbeats.get(session_id, 0.0)" in patched
|
|
assert '"subagent.thinking",' in patched
|
|
assert "Stream retention and run lifetime are separate" in patched
|
|
assert 'terminal_status in {"completed", "failed", "cancelled"}' in patched
|
|
assert patched.index("terminal_status = self._run_statuses") < patched.index(
|
|
"self._active_run_tasks.pop(run_id, None)"
|
|
)
|
|
|
|
|
|
def test_telegram_api_session_migration_is_bounded_and_idempotent(tmp_path: Path):
|
|
"""Only named Telegram conversations receive presentation metadata."""
|
|
module_path = HERMES / "scripts" / "migrate_telegram_api_sessions.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"migrate_telegram_sessions", module_path
|
|
)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
state = tmp_path / "state.db"
|
|
responses = tmp_path / "response_store.db"
|
|
with sqlite3.connect(state) as connection:
|
|
connection.execute(
|
|
"""CREATE TABLE sessions (
|
|
id TEXT PRIMARY KEY, source TEXT, session_key TEXT, chat_type TEXT,
|
|
display_name TEXT, origin_json TEXT, title TEXT
|
|
)"""
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO sessions (id, source) VALUES (?, ?)",
|
|
(("telegram-session", "api_server"), ("other-session", "api_server")),
|
|
)
|
|
with sqlite3.connect(responses) as connection:
|
|
connection.execute(
|
|
"CREATE TABLE conversations (name TEXT PRIMARY KEY, response_id TEXT NOT NULL)"
|
|
)
|
|
connection.execute(
|
|
"CREATE TABLE responses (response_id TEXT PRIMARY KEY, data TEXT NOT NULL, accessed_at REAL NOT NULL)"
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO responses VALUES (?, ?, 0)",
|
|
(
|
|
("telegram-response", json.dumps({"session_id": "telegram-session"})),
|
|
("other-response", json.dumps({"session_id": "other-session"})),
|
|
),
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO conversations VALUES (?, ?)",
|
|
(("telegram", "telegram-response"), ("unrelated", "other-response")),
|
|
)
|
|
|
|
assert module.migrate(state, responses) == 1
|
|
assert module.migrate(state, responses) == 0
|
|
with sqlite3.connect(state) as connection:
|
|
telegram = connection.execute(
|
|
"SELECT session_key, chat_type, display_name, origin_json, title "
|
|
"FROM sessions WHERE id = 'telegram-session'"
|
|
).fetchone()
|
|
other = connection.execute(
|
|
"SELECT session_key, title FROM sessions WHERE id = 'other-session'"
|
|
).fetchone()
|
|
assert telegram[:3] == ("telegram", "private", "Telegram")
|
|
assert json.loads(telegram[3]) == {
|
|
"platform": "telegram",
|
|
"session_key": "telegram",
|
|
}
|
|
assert telegram[4] == "Telegram · General"
|
|
assert other == (None, None)
|
|
|
|
|
|
def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
|
|
"""The DOM transcript includes events without polluting agent history."""
|
|
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
|
spec = importlib.util.spec_from_file_location("patch_web_activity", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
source = tmp_path / "web_server.py"
|
|
destination = tmp_path / "patched.py"
|
|
source.write_text(
|
|
"prefix\n"
|
|
+ module.LATEST_ROWS_BEFORE
|
|
+ module.LATEST_SELECTION_BEFORE
|
|
+ module.HELPER_MARKER
|
|
+ " db = object()\n"
|
|
+ module.MESSAGES_BEFORE
|
|
+ "suffix\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
module.patch(source, destination)
|
|
patched = destination.read_text(encoding="utf-8")
|
|
|
|
assert "def _run_activity_messages(" in patched
|
|
assert 'root = get_hermes_home() / "run-activity"' in patched
|
|
assert "path.stat().st_size > 600_000" in patched
|
|
assert "entries[-1_000:]" in patched
|
|
assert "*_run_activity_messages(sid)" in patched
|
|
assert "messages.sort(" in patched
|
|
assert "limit: Optional[int] = None" in patched
|
|
assert "total_messages = len(messages)" in patched
|
|
assert "min(int(limit), 10_000)" in patched
|
|
assert '"total_messages": total_messages' in patched
|
|
assert "SELECT id, parent_session_id, started_at, ended_at" in patched
|
|
assert "newest still-open member of the lineage" in patched
|
|
assert "immediate objective parent" in patched
|
|
assert "mixes unrelated workstreams" in patched
|
|
assert "orphaned children" in patched
|
|
assert 'item[0].get("ended_at") is None' in patched
|
|
|
|
|
|
def test_web_session_lineage_returns_to_resumed_parent():
|
|
"""An ended reviewer must not strand the live view away from its parent."""
|
|
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
|
spec = importlib.util.spec_from_file_location("patch_web_lineage", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
|
|
namespace: dict[str, object] = {}
|
|
exec(
|
|
"def select_active(sid, db, rows):\n" + module.LATEST_SELECTION_AFTER,
|
|
namespace,
|
|
)
|
|
|
|
class FakeDB:
|
|
def get_session(self, session_id):
|
|
return next((row for row in rows if row["id"] == session_id), None)
|
|
|
|
rows = [
|
|
{
|
|
"id": "umbrella",
|
|
"parent_session_id": None,
|
|
"started_at": 0.0,
|
|
"ended_at": None,
|
|
},
|
|
{
|
|
"id": "root",
|
|
"parent_session_id": "umbrella",
|
|
"started_at": 1.0,
|
|
"ended_at": None,
|
|
},
|
|
{
|
|
"id": "review-1",
|
|
"parent_session_id": "root",
|
|
"started_at": 2.0,
|
|
"ended_at": 3.0,
|
|
},
|
|
{
|
|
"id": "review-2",
|
|
"parent_session_id": "root",
|
|
"started_at": 4.0,
|
|
"ended_at": None,
|
|
},
|
|
]
|
|
select_active = namespace["select_active"]
|
|
|
|
assert select_active("root", FakeDB(), rows) == (
|
|
"review-2",
|
|
["root", "review-2"],
|
|
)
|
|
assert select_active("review-1", FakeDB(), rows) == (
|
|
"review-2",
|
|
["root", "review-2"],
|
|
)
|
|
rows[2]["ended_at"] = 5.0
|
|
rows[3]["ended_at"] = 5.0
|
|
assert select_active("root", FakeDB(), rows) == ("root", ["root"])
|
|
|
|
rows[1]["ended_at"] = 9.0
|
|
rows.append(
|
|
{
|
|
"id": "orphaned-review",
|
|
"parent_session_id": "root",
|
|
"started_at": 6.0,
|
|
"ended_at": None,
|
|
}
|
|
)
|
|
rows.append(
|
|
{
|
|
"id": "accepted-review",
|
|
"parent_session_id": "root",
|
|
"started_at": 7.0,
|
|
"ended_at": 8.0,
|
|
}
|
|
)
|
|
assert select_active("review-1", FakeDB(), rows) == (
|
|
"accepted-review",
|
|
["root", "accepted-review"],
|
|
)
|
|
|
|
|
|
def test_api_activity_patch_coalesces_streaming_heartbeats(
|
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
):
|
|
"""Token callbacks stay bounded while every tool transition is retained."""
|
|
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
|
|
spec = importlib.util.spec_from_file_location("patch_api_activity", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
activity_body = module.EVENT_CALLBACK_SIGNATURE_AFTER.split(
|
|
" def _make_run_event_callback(", 1
|
|
)[0]
|
|
namespace: dict[str, object] = {}
|
|
exec(
|
|
"import hashlib, json, logging, os, time\n"
|
|
"from pathlib import Path\n"
|
|
"logger = logging.getLogger(__name__)\n"
|
|
"def redact_sensitive_text(value): return value\n"
|
|
"class ActivityRecorder:\n" + activity_body,
|
|
namespace,
|
|
)
|
|
recorder = namespace["ActivityRecorder"]()
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
|
|
recorder._record_run_activity("session", "_thinking")
|
|
recorder._record_run_activity("session", "reasoning.available")
|
|
recorder._record_run_activity("session", "subagent.thinking")
|
|
recorder._record_run_activity("session", "tool.started", tool_name="terminal")
|
|
recorder._record_run_activity("session", "tool.completed", tool_name="terminal")
|
|
|
|
journals = list((tmp_path / "run-activity").glob("*.jsonl"))
|
|
assert len(journals) == 1
|
|
entries = [json.loads(line) for line in journals[0].read_text().splitlines()]
|
|
assert [entry["activity_event"] for entry in entries] == [
|
|
"_thinking",
|
|
"tool.started",
|
|
"tool.completed",
|
|
]
|
|
|
|
|
|
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
|
|
"""Known standalone API workers move under Cassandra without data loss."""
|
|
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
|
spec = importlib.util.spec_from_file_location("migrate_api_sessions", module_path)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
database = tmp_path / "state.db"
|
|
with sqlite3.connect(database) as connection:
|
|
connection.execute(
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
|
"parent_session_id TEXT, title TEXT, transcript TEXT, archived INTEGER DEFAULT 0)"
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
|
"VALUES (?, 'tui', NULL, 'Cassandra', 'parent-data')",
|
|
(module.LEGACY_CASSANDRA_PARENT,),
|
|
)
|
|
worker_id = next(iter(module.LEGACY_CASSANDRA_WORKERS))
|
|
connection.execute(
|
|
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
|
"VALUES (?, 'api_server', NULL, 'old', 'keep-me')",
|
|
(worker_id,),
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
|
"VALUES (?, 'api_server', NULL, 'old smoke', 'keep-smoke')",
|
|
((orphan_id,) for orphan_id in module.LEGACY_ORPHANED_SMOKE_SESSIONS),
|
|
)
|
|
|
|
assert module.migrate(database) == 1 + len(module.LEGACY_ORPHANED_SMOKE_SESSIONS)
|
|
assert module.migrate(database) == 0
|
|
with sqlite3.connect(database) as connection:
|
|
row = connection.execute(
|
|
"SELECT parent_session_id, title, transcript FROM sessions WHERE id = ?",
|
|
(worker_id,),
|
|
).fetchone()
|
|
assert row == (
|
|
module.LEGACY_CASSANDRA_PARENT,
|
|
module.LEGACY_CASSANDRA_WORKERS[worker_id],
|
|
"keep-me",
|
|
)
|
|
with sqlite3.connect(database) as connection:
|
|
orphans = connection.execute(
|
|
"SELECT id, archived, title, transcript FROM sessions "
|
|
"WHERE id IN ({}) ORDER BY id".format(
|
|
",".join("?" for _ in module.LEGACY_ORPHANED_SMOKE_SESSIONS)
|
|
),
|
|
tuple(module.LEGACY_ORPHANED_SMOKE_SESSIONS),
|
|
).fetchall()
|
|
assert orphans == sorted(
|
|
(
|
|
orphan_id,
|
|
1,
|
|
module.LEGACY_ORPHANED_SMOKE_SESSIONS[orphan_id],
|
|
"keep-smoke",
|
|
)
|
|
for orphan_id in module.LEGACY_ORPHANED_SMOKE_SESSIONS
|
|
)
|
|
|
|
|
|
def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs(
|
|
tmp_path: Path,
|
|
):
|
|
"""Only the stable Ariadne contract moves below the triage parent."""
|
|
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
|
spec = importlib.util.spec_from_file_location(
|
|
"migrate_triage_sessions", module_path
|
|
)
|
|
assert spec and spec.loader
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
database = tmp_path / "state.db"
|
|
with sqlite3.connect(database) as connection:
|
|
connection.execute(
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
|
"parent_session_id TEXT, title TEXT, started_at REAL, archived INTEGER DEFAULT 0)"
|
|
)
|
|
connection.execute(
|
|
"CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT)"
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO sessions (id, source, started_at) VALUES (?, 'api_server', ?)",
|
|
(("triage-run", 1.0), ("jenkins-run", 1.5), ("interactive-run", 2.0)),
|
|
)
|
|
connection.executemany(
|
|
"INSERT INTO messages (session_id, role, content) VALUES (?, 'user', ?)",
|
|
(
|
|
(
|
|
"triage-run",
|
|
module.TRIAGE_MESSAGE_PREFIXES[0]
|
|
+ " Fix for incident sonar/bstein_home/python:S2208/finding-key.",
|
|
),
|
|
(
|
|
"jenkins-run",
|
|
module.TRIAGE_MESSAGE_PREFIXES[1]
|
|
+ "\nAnalyze incident soteria/291 for the Jenkins job soteria.",
|
|
),
|
|
("interactive-run", "Please explain this alert to me."),
|
|
),
|
|
)
|
|
|
|
assert module.migrate(database, group_triage=True) == 2
|
|
assert module.migrate(database, group_triage=True) == 0
|
|
with sqlite3.connect(database) as connection:
|
|
parent = connection.execute(
|
|
"SELECT title FROM sessions WHERE id = ?", (module.TRIAGE_PARENT,)
|
|
).fetchone()
|
|
triage = connection.execute(
|
|
"SELECT parent_session_id, title FROM sessions WHERE id = 'triage-run'"
|
|
).fetchone()
|
|
interactive = connection.execute(
|
|
"SELECT parent_session_id FROM sessions WHERE id = 'interactive-run'"
|
|
).fetchone()
|
|
jenkins = connection.execute(
|
|
"SELECT parent_session_id, title FROM sessions WHERE id = 'jenkins-run'"
|
|
).fetchone()
|
|
assert parent == (module.TRIAGE_PARENT_TITLE,)
|
|
assert triage == (
|
|
module.TRIAGE_PARENT,
|
|
"Sonar · bstein_home · python:S2208 · iage-run",
|
|
)
|
|
assert interactive == (None,)
|
|
assert jenkins == (
|
|
module.TRIAGE_PARENT,
|
|
"Sonar · soteria/291 · kins-run",
|
|
)
|