mirror of
https://github.com/Colton-z/AstraBox.git
synced 2026-09-28 06:02:56 +08:00
The open-source, self-hosted alternative to Claude Managed Agents: run Claude Code, Codex, Hermes, DeepSeek Harness and Pi as managed Agents on your own infrastructure, with conversations that start and resume in seconds.
241 lines
9.5 KiB
Python
241 lines
9.5 KiB
Python
"""The transcript export: what an operator downloads has to resume in Claude Code.
|
|
|
|
Every assertion here is about that one outcome. The SDK's contract is that a
|
|
``SessionStoreEntry`` is one JSONL line, opaque, in append order, and that a
|
|
transcript restored under ``~/.claude/projects/`` resumes by SDK session id. So
|
|
the export is wrong if it reshapes an entry, reorders them, names the file after
|
|
the platform's id, or folds a subagent's separate transcript into the main one —
|
|
and each of those is a case below.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from astrabox.common.utils.errors import APIError
|
|
from astrabox.core.service.orchestrator.admin_service import AdminService
|
|
|
|
PLATFORM_ID = "plat-1111"
|
|
SDK_ID = "1eca6b76-655d-49c5-b8e8-90d39873f437"
|
|
PROJECT_KEY = "-home-agent-workspace"
|
|
|
|
MAIN_ENTRIES = [
|
|
{"type": "user", "uuid": "u-1", "message": {"role": "user", "content": "hi"}},
|
|
{"type": "assistant", "uuid": "a-1", "message": {"role": "assistant", "content": "hello"}},
|
|
# A shape nothing in this repo models: it must survive untouched.
|
|
{"type": "x-unknown-kind", "uuid": "z-1", "nested": {"deep": [1, {"k": None}]}},
|
|
]
|
|
SUB_ENTRIES = [{"type": "assistant", "uuid": "s-1", "message": {"content": "sub work"}}]
|
|
|
|
|
|
class _TranscriptRepo:
|
|
def __init__(self, scopes: list[dict[str, Any]], entries: dict[str | None, list[dict]]):
|
|
self._scopes = scopes
|
|
self._entries = entries
|
|
self.loaded: list[tuple[str, str, str | None]] = []
|
|
|
|
async def list_scopes_by_platform_session(self, platform_session_id: str):
|
|
return list(self._scopes) if platform_session_id == PLATFORM_ID else []
|
|
|
|
async def load_entries(self, project_key, session_id, subpath, *, platform_session_id=None):
|
|
self.loaded.append((project_key, session_id, subpath))
|
|
return list(self._entries.get(subpath) or [])
|
|
|
|
|
|
class _SessionsRepo:
|
|
def __init__(self, session: dict[str, Any] | None, rows: list[dict[str, Any]] | None = None):
|
|
self._session = session
|
|
self._rows = rows or []
|
|
self.queries: list[dict[str, Any]] = []
|
|
|
|
async def get_session(self, session_id: str):
|
|
return self._session if self._session and session_id == PLATFORM_ID else None
|
|
|
|
async def list_all_sessions(
|
|
self, limit=200, *, skip=0, template_names=None, agent_id=None, since=None, until=None
|
|
):
|
|
self.queries.append(
|
|
{"limit": limit, "skip": skip, "template_names": template_names,
|
|
"agent_id": agent_id, "since": since, "until": until}
|
|
)
|
|
rows = self._rows
|
|
if agent_id:
|
|
rows = [r for r in rows if r.get("agent_id") == agent_id]
|
|
return rows[skip : skip + limit]
|
|
|
|
|
|
def _service(*, scopes, entries, session=None, can_manage=True) -> AdminService:
|
|
svc = AdminService.__new__(AdminService)
|
|
svc._sessions_repo = _SessionsRepo( # type: ignore[attr-defined]
|
|
session if session is not None else {"session_id": PLATFORM_ID, "agent_id": "ag-1"}
|
|
)
|
|
svc._transcript_entries_repo = _TranscriptRepo(scopes, entries) # type: ignore[attr-defined]
|
|
|
|
async def _assert(user: Any, row: Any) -> None:
|
|
if not can_manage:
|
|
raise APIError(code="FORBIDDEN", message="nope", status_code=403)
|
|
|
|
svc._assert_can_manage_session = _assert # type: ignore[assignment]
|
|
return svc
|
|
|
|
|
|
MAIN_SCOPE = {"project_key": PROJECT_KEY, "session_id": SDK_ID, "subpath": None}
|
|
SUB_SCOPE = {"project_key": PROJECT_KEY, "session_id": SDK_ID, "subpath": "subagents/agent-7"}
|
|
|
|
|
|
async def test_entries_are_one_jsonl_line_each_in_append_order() -> None:
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES})
|
|
|
|
files = await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert len(files) == 1
|
|
lines = files[0]["jsonl"].decode("utf-8").splitlines()
|
|
assert len(lines) == len(MAIN_ENTRIES)
|
|
assert [json.loads(line) for line in lines] == MAIN_ENTRIES
|
|
|
|
|
|
async def test_an_entry_is_never_reshaped() -> None:
|
|
"""The store contract is deep-equal round-trip, so nothing may be added.
|
|
|
|
A summary field, a turn stamp, an envelope — any of them makes the line
|
|
something the SDK did not write, and the file stops being the SDK's.
|
|
"""
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES})
|
|
|
|
files = await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
for line, original in zip(files[0]["jsonl"].decode().splitlines(), MAIN_ENTRIES):
|
|
assert json.loads(line).keys() == original.keys()
|
|
|
|
|
|
async def test_the_file_is_named_for_the_sdk_session_not_the_platform_one() -> None:
|
|
"""`--resume` takes the SDK's id; a file named for the platform id is one Claude Code cannot find."""
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES})
|
|
|
|
files = await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert files[0]["sdk_session_id"] == SDK_ID
|
|
assert SDK_ID in files[0]["path"]
|
|
assert PLATFORM_ID not in files[0]["path"]
|
|
|
|
|
|
async def test_a_subagent_transcript_is_its_own_file_at_the_sdk_path() -> None:
|
|
"""Separate SessionStore keys are separate files on disk, and stay separate.
|
|
|
|
Folding them into one list — which the recovery read does, by seq — puts a
|
|
subagent's lines inside the main conversation, and the main transcript stops
|
|
being loadable.
|
|
"""
|
|
svc = _service(
|
|
scopes=[MAIN_SCOPE, SUB_SCOPE],
|
|
entries={None: MAIN_ENTRIES, "subagents/agent-7": SUB_ENTRIES},
|
|
)
|
|
|
|
files = await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert sorted(f["path"] for f in files) == sorted(
|
|
[f"{PROJECT_KEY}/{SDK_ID}.jsonl", f"{PROJECT_KEY}/subagents/agent-7.jsonl"]
|
|
)
|
|
main = next(f for f in files if f["subpath"] is None)
|
|
assert b"sub work" not in main["jsonl"]
|
|
|
|
|
|
async def test_a_session_with_no_mirrored_transcript_yields_no_files() -> None:
|
|
"""Not an empty file. A sandbox that never started wrote nothing, and a
|
|
zero-byte `.jsonl` in an archive reads as a session that did."""
|
|
svc = _service(scopes=[], entries={})
|
|
|
|
files = await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert files == []
|
|
|
|
|
|
async def test_an_unknown_session_is_404_not_an_empty_export() -> None:
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES}, session={})
|
|
svc._sessions_repo = _SessionsRepo(None) # type: ignore[attr-defined]
|
|
|
|
with pytest.raises(APIError) as excinfo:
|
|
await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert excinfo.value.status_code == 404
|
|
|
|
|
|
async def test_the_agent_gate_is_enforced_before_any_transcript_is_read() -> None:
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES}, can_manage=False)
|
|
|
|
with pytest.raises(APIError) as excinfo:
|
|
await svc.admin_session_transcript_files(SimpleNamespace(user_id="u"), PLATFORM_ID)
|
|
|
|
assert excinfo.value.status_code == 403
|
|
assert svc._transcript_entries_repo.loaded == [] # type: ignore[attr-defined]
|
|
|
|
|
|
async def test_batch_walks_pages_and_skips_what_has_no_transcript() -> None:
|
|
"""A generator over a PAGED walk, so neither the rows nor the archive is resident.
|
|
|
|
The shape this replaces read one capped list and built the whole tar in
|
|
memory. At a hundred agents and a thousand conversations a day, a cap is not
|
|
a detail: an operator asking for one agent's day would have got its first
|
|
five hundred rows and no sign of the rest.
|
|
"""
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES})
|
|
svc._sessions_repo = _SessionsRepo( # type: ignore[attr-defined]
|
|
{"session_id": PLATFORM_ID, "agent_id": "ag-1"},
|
|
rows=[
|
|
{"session_id": PLATFORM_ID, "agent_id": "ag-1"},
|
|
{"session_id": "plat-gone", "agent_id": "ag-1"},
|
|
{"session_id": "", "agent_id": "ag-1"},
|
|
],
|
|
)
|
|
|
|
async def _manageable(user: Any):
|
|
return {"Claude Code"}
|
|
|
|
svc._manageable_template_names = _manageable # type: ignore[assignment]
|
|
|
|
groups = [g async for g in svc.admin_iter_batch_transcript_files(SimpleNamespace(user_id="u"))]
|
|
|
|
# plat-gone 404s on its per-session read; the "" row is unusable. Neither
|
|
# may end the archive.
|
|
assert [g["session_id"] for g in groups] == [PLATFORM_ID]
|
|
|
|
|
|
async def test_batch_pushes_the_filters_into_the_query() -> None:
|
|
"""The narrowing is a query term, not a pass over the result.
|
|
|
|
Filtering one fetched page could produce a partial export, so the repository
|
|
applies the filters before pagination.
|
|
"""
|
|
svc = _service(scopes=[MAIN_SCOPE], entries={None: MAIN_ENTRIES})
|
|
repo = _SessionsRepo(
|
|
{"session_id": PLATFORM_ID, "agent_id": "ag-1"},
|
|
rows=[{"session_id": PLATFORM_ID, "agent_id": "ag-1"}],
|
|
)
|
|
svc._sessions_repo = repo # type: ignore[attr-defined]
|
|
|
|
async def _manageable(user: Any):
|
|
return {"Claude Code"}
|
|
|
|
svc._manageable_template_names = _manageable # type: ignore[assignment]
|
|
|
|
_ = [
|
|
g
|
|
async for g in svc.admin_iter_batch_transcript_files(
|
|
SimpleNamespace(user_id="u"),
|
|
agent_id="ag-1",
|
|
since="2026-08-01T00:00:00+00:00",
|
|
until="2026-08-07T23:59:59+00:00",
|
|
)
|
|
]
|
|
|
|
q = repo.queries[0]
|
|
assert q["agent_id"] == "ag-1"
|
|
assert q["since"] == "2026-08-01T00:00:00+00:00"
|
|
assert q["until"] == "2026-08-07T23:59:59+00:00"
|
|
# The owner scope rides in the same query, never as a later comprehension.
|
|
assert q["template_names"] == ["Claude Code"]
|