feat(P03): live surfaces — read-models, Reading Room fidelity, standalone client #19

Merged
coreci-bot merged 10 commits from phase/03-live-surfaces into milestone/v0.3-hosted 2026-09-30 14:22:55 +00:00
17 changed files with 5728 additions and 346 deletions
+225 -10
View File
@@ -1,4 +1,5 @@
import asyncio
import json
import time
from collections.abc import AsyncIterator
from pathlib import Path
@@ -13,15 +14,16 @@ from researchx.adapters.capability_probe import probe_model_adapter, supports_pr
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.model_factory import ModelAdapterFactory
from researchx.adapters.types import AdapterError, ChatResult
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
from researchx.api.read_models import connections, graph, library, list_runs, suggestions, tasks
from researchx.auth.admin_routes import build_admin_router
from researchx.auth.guard import Guard, build_admin_guard, build_dev_guard, build_guard
from researchx.auth.key_store import KEY_SCOPE, KeyStore, KeyStoreError
from researchx.auth.models import UserPublic
from researchx.auth.models import _EMAIL_RE, UserPublic
from researchx.auth.routes import build_auth_router
from researchx.auth.sessions import SessionStore
from researchx.auth.settings import SettingsStore, SettingsStoreError, UserSettings
from researchx.auth.users import UserStore
from researchx.auth.users import UserError, UserStore
from researchx.config.paths import data_root
from researchx.engine.actor import RunActor, make_run_config
from researchx.engine.events import EventBus, RunEvent
@@ -657,27 +659,240 @@ def create_app(
@protected.get("/api/runs/{run_id}/report")
async def run_report(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
"""Disk-read, registry-independent (P-8 full closure, T-3.3):
the run directory under THIS owner is the existence check — no
RunRegistry involvement, correct after a restart with zero
in-memory actors. Pre-artifact DONE runs (and any run whose
report.json was not yet written) fall back to markdown-only
with null report/evidence — never a 500 (D-106: old artifacts
disposable)."""
storage = _owner_storage(user)
if not (storage.run_dir(run_id)).is_dir():
raise HTTPException(status_code=404, detail="run not found")
try:
content = await _owner_storage(user).read_run_artifact(run_id, "report.md")
markdown = await storage.read_run_artifact(run_id, "report.md")
except AdapterError:
raise HTTPException(status_code=404, detail="report not ready")
return {"run_id": run_id, "markdown": content}
report: dict[str, Any] | None = None
evidence: dict[str, Any] | None = None
try:
parsed = json.loads(await storage.read_run_artifact(run_id, "report.json"))
if isinstance(parsed, dict):
report = parsed
except (AdapterError, json.JSONDecodeError):
pass
try:
parsed = json.loads(await storage.read_run_artifact(run_id, "evidence.json"))
if isinstance(parsed, dict):
evidence = parsed
except (AdapterError, json.JSONDecodeError):
pass
return {"run_id": run_id, "markdown": markdown, "report": report, "evidence": evidence}
@protected.get("/api/runs/{run_id}/ledger")
async def run_ledger(run_id: str, user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
if not runs.has(user.user_id, run_id):
"""Disk-read, registry-independent (T-3.3): same owner-dir
existence check as the report route."""
storage = _owner_storage(user)
if not (storage.run_dir(run_id)).is_dir():
raise HTTPException(status_code=404, detail="run not found")
try:
content = await _owner_storage(user).read_run_artifact(run_id, "ledger.json")
content = await storage.read_run_artifact(run_id, "ledger.json")
except AdapterError:
raise HTTPException(status_code=404, detail="ledger not ready")
import json
data: dict[str, Any] = json.loads(content)
return data
# ------------------------------------------------------------- T-3.2/T-3.3
# Live surfaces (REQ-76..79): read-model routes — disk reads only,
# owner-scoped via the guard, NEVER the in-memory RunRegistry
# (D-115). Every route below is correct after a restart with zero
# in-process actors (P-8 full closure, pinned by test).
@protected.get("/api/runs")
async def list_runs_route(user: Annotated[UserPublic, Depends(guard)]) -> list[RunSummary]:
"""All of this owner's runs from disk state files, most recent
first — the registry-independent run list (post-sweep
CANCELLED runs surface cancelled, NOT open)."""
records = list_runs(storage_base, user.user_id)
summaries: list[RunSummary] = []
for record in records:
plan = record.state.get("plan")
summaries.append(
RunSummary(
id=record.run_id,
status=_STATUS_MAP.get(RunStateName(record.status), "failed"),
question=record.question,
methodology=record.methodology_slug,
plan=plan if isinstance(plan, dict) else None,
started_at=record.created_at,
)
)
return summaries
@protected.get("/api/library")
async def library_route(
user: Annotated[UserPublic, Depends(guard)], sort: str = "recent"
) -> dict[str, Any]:
return library(storage_base, user.user_id, sort=sort)
@protected.get("/api/library/connections")
async def connections_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, Any]]:
return connections(storage_base, user.user_id)
@protected.get("/api/graph")
async def graph_route(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, Any]:
return graph(storage_base, user.user_id)
@protected.get("/api/tasks")
async def tasks_route(user: Annotated[UserPublic, Depends(guard)]) -> list[dict[str, str]]:
return tasks(storage_base, user.user_id)
@protected.get("/api/suggestions")
async def suggestions_route(
user: Annotated[UserPublic, Depends(guard)]
) -> list[dict[str, str]]:
return suggestions(storage_base, user.user_id)
# ------------------------------------------------------------- T-3.6
# Refine + profile (REQ-80/81). The refine planner calls ride the
# SAME per-user credential chain as runs (P2 factory); an
# unavailable model is an honest 503, never a hang.
class RefineDraftBody(BaseModel):
draft: str = Field(min_length=1, max_length=2000)
class RefineComposeBody(BaseModel):
draft: str = Field(min_length=1, max_length=2000)
answers: list[str] = Field(default_factory=list, max_length=10)
_REFINE_QUESTIONS_PROMPT = (
"You are a research question refiner. The user drafted a research "
"question. Reply with a JSON object {{\"questions\": [...]}} holding "
"exactly three short clarifying questions that would sharpen the "
"draft. No commentary, JSON only.\nDraft: {draft}"
)
_REFINE_COMPOSE_PROMPT = (
"You are a research question refiner. The user drafted a research "
"question and answered clarifying questions. Compose the refined "
"research question and a short suggested-context paragraph. Reply "
"with a JSON object {{\"question\": \"...\", \"context\": \"...\"}}, "
"JSON only.\nDraft: {draft}\nAnswers: {answers}"
)
def _parse_refine_json(content: str) -> dict[str, Any]:
try:
parsed = json.loads(content)
except json.JSONDecodeError as exc:
raise AdapterError("refine planner returned non-JSON") from exc
if not isinstance(parsed, dict):
raise AdapterError("refine planner returned non-JSON")
return parsed
async def _refine_model(user: UserPublic) -> tuple[Any, str]:
"""Resolve the per-user adapter for a refine call — the same
chain as create_run (user -> server -> keyless), one fresh
adapter instance per call (P-2)."""
if model_factory is not None:
settings = await settings_store.get(user.user_id)
run_model, tier = await model_factory.resolve(user, settings)
return run_model, tier
if model is None: # factory wired but absent — impossible via create_app
raise AdapterError("no model adapter available")
return model, "keyless"
@protected.post("/api/refine/questions")
async def refine_questions(
body: RefineDraftBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, Any]:
draft = body.draft.strip()
if not draft:
raise HTTPException(status_code=422, detail="draft must not be empty")
try:
adapter, _tier = await _refine_model(user)
result = await adapter.chat(
[ModelMessage(role="user", content=_REFINE_QUESTIONS_PROMPT.format(draft=draft))],
model=adapter.roles().planner,
json_mode=True,
)
except AdapterError as exc:
# Honest 503 (T-3.6): the resolved tier is unavailable —
# never a hang, never a silent success.
raise HTTPException(status_code=503, detail=str(exc)) from exc
parsed = _parse_refine_json(result.content)
raw = parsed.get("questions", [])
questions = [str(q).strip() for q in raw if str(q).strip()] if isinstance(raw, list) else []
if not questions:
raise HTTPException(status_code=503, detail="refine planner returned no questions")
return {"questions": questions[:3]}
@protected.post("/api/refine/compose")
async def refine_compose(
body: RefineComposeBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, str]:
draft = body.draft.strip()
if not draft:
raise HTTPException(status_code=422, detail="draft must not be empty")
answers = [str(a).strip() for a in body.answers if str(a).strip()]
try:
adapter, _tier = await _refine_model(user)
result = await adapter.chat(
[
ModelMessage(
role="user",
content=_REFINE_COMPOSE_PROMPT.format(
draft=draft, answers=json.dumps(answers)
),
)
],
model=adapter.roles().planner,
json_mode=True,
)
except AdapterError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
parsed = _parse_refine_json(result.content)
question = str(parsed.get("question", "")).strip()
if not question:
raise HTTPException(status_code=503, detail="refine planner returned no question")
return {"question": question, "context": str(parsed.get("context", "")).strip()}
# ------------------------------------------------------------- profile
class ProfileBody(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=120)
email: str | None = Field(default=None, pattern=_EMAIL_RE, max_length=254)
@protected.get("/api/profile")
async def get_profile(user: Annotated[UserPublic, Depends(guard)]) -> dict[str, str]:
return {
"user_id": user.user_id,
"display_name": user.display_name,
"email": user.email,
}
@protected.put("/api/profile")
async def put_profile(
body: ProfileBody, user: Annotated[UserPublic, Depends(guard)]
) -> dict[str, str]:
"""Update display name and/or email (no deletion, REQ-81).
The email stays unique (casefolded) across other users."""
stored = users.get(user.user_id)
if stored is None:
raise HTTPException(status_code=401, detail="account disabled")
try:
updated = await users.update_profile(
user.user_id,
display_name=body.display_name,
email=body.email,
)
except UserError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return {
"user_id": updated.user_id,
"display_name": updated.display_name,
"email": updated.email,
}
app.include_router(public)
app.include_router(protected)
+505
View File
@@ -0,0 +1,505 @@
"""Disk read-models (T-3.2, D-114, D-115 — REQ-76..79).
Pure functions over ``runs/<owner_id>/<run_id>/`` disk artifacts —
NEVER the in-memory RunRegistry (D-115): every read endpoint stays
correct after a restart with zero in-process actors (P-8 full
closure). Payloads are shaped EXACTLY to the existing frontend types
(``frontend/src/lib/types.ts``): ``ReportLibrary``/``ReportSummary``,
``ReportConnection``, ``ProjectGraph`` nodes/edges, ``InboxItem``,
``SuggestedQuery`` — camelCase, field-for-field, so T-3.9's surface
swaps need zero component rewrites (REQ-54).
Categorization is deterministic + dumb by design: a methodology
folder (``deep-report``/``rapid-answer``) plus topic tokens from plan
sub-questions. No model calls, no synthesis — the same disk tree
always categorizes identically.
Open-question semantics (D-114): inbox items are plan sub-questions
of the user's runs, ``done`` iff the run reached DONE — anything
else (post-sweep CANCELLED included) stays ``open``; suggestions are
those open items joined with source run titles — never synthetic.
"""
import json
import re
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
# --------------------------------------------------------------- wire shapes
@dataclass(frozen=True)
class ReportFolder:
"""matches frontend ReportFolder — id == ReportSummary.category."""
id: str
name: str
description: str
def payload(self) -> dict[str, str]:
return {"id": self.id, "name": self.name, "description": self.description}
@dataclass(frozen=True)
class ReportSummary:
"""matches frontend ReportSummary — the library card."""
id: str
runId: str
title: str
question: str
methodologySlug: str
category: str
generatedAt: str
citationCount: int
def payload(self) -> dict[str, Any]:
return {
"id": self.id,
"runId": self.runId,
"title": self.title,
"question": self.question,
"methodologySlug": self.methodologySlug,
"category": self.category,
"generatedAt": self.generatedAt,
"citationCount": self.citationCount,
}
@dataclass(frozen=True)
class ReportConnection:
"""matches frontend ReportConnection — a shared-source/topic edge."""
fromReportId: str
toReportId: str
kind: str # "shared-sources" | "related-topic"
label: str
def payload(self) -> dict[str, str]:
return {
"fromReportId": self.fromReportId,
"toReportId": self.toReportId,
"kind": self.kind,
"label": self.label,
}
@dataclass(frozen=True)
class GraphNode:
id: str
topic: str
linkedSources: list[str]
findings: list[str]
def payload(self) -> dict[str, Any]:
return {
"id": self.id,
"topic": self.topic,
"linkedSources": self.linkedSources,
"findings": self.findings,
}
@dataclass(frozen=True)
class GraphEdge:
fromId: str
toId: str
sharedSourceCount: int
def payload(self) -> dict[str, Any]:
return {
"fromId": self.fromId,
"toId": self.toId,
"sharedSourceCount": self.sharedSourceCount,
}
@dataclass(frozen=True)
class InboxItem:
"""matches frontend InboxItem — "<run_id>#<idx>" ids (D-114)."""
id: str
question: str
sourceRunId: str
status: str # "open" | "done" (in_progress is mock-only vocabulary)
def payload(self) -> dict[str, str]:
return {
"id": self.id,
"question": self.question,
"sourceRunId": self.sourceRunId,
"status": self.status,
}
@dataclass(frozen=True)
class SuggestedQuery:
"""matches frontend SuggestedQuery — an open item + run title."""
id: str
question: str
sourceRunId: str
sourceReportTitle: str
def payload(self) -> dict[str, str]:
return {
"id": self.id,
"question": self.question,
"sourceRunId": self.sourceRunId,
"sourceReportTitle": self.sourceReportTitle,
}
# ------------------------------------------------------------- disk scanning
_METHOD_NAMES = {"rapid-answer": "Rapid Answer", "deep-report": "Deep Report"}
_QUOTE_MAX = 200
def _is_scannable(run_dir: Path) -> bool:
"""Defensive scan filter (D-115): dotfiles/tmp leftovers skipped."""
name = run_dir.name
return run_dir.is_dir() and not name.startswith(".") and ".tmp" not in name
@dataclass
class RunDiskRecord:
"""One run as it sits on disk — the read-model's only input."""
run_id: str
state: dict[str, Any]
report: dict[str, Any] | None = None
ledger: dict[str, Any] | None = None
# derived, filled by load:
status: str = field(default="", init=False)
def __post_init__(self) -> None:
self.status = str(self.state.get("state", ""))
@property
def is_done(self) -> bool:
return self.status == "DONE"
@property
def question(self) -> str:
return str(self.state.get("question", ""))
@property
def methodology_slug(self) -> str:
return str(self.state.get("methodology_slug", ""))
@property
def created_at(self) -> str:
return str(self.state.get("created_at", ""))
@property
def sub_questions(self) -> list[str]:
plan = self.state.get("plan")
if not isinstance(plan, dict):
return []
raw = plan.get("sub_questions", [])
if not isinstance(raw, list):
return []
return [str(q) for q in raw]
def report_title(self) -> str:
"""The report.json title, else methodology-name — question."""
if self.report is not None and self.report.get("title"):
return str(self.report["title"])
name = _METHOD_NAMES.get(self.methodology_slug, "Research")
return f"{name} — {self.question}"
def citation_count(self) -> int:
if self.report is not None:
citations = self.report.get("citations")
if isinstance(citations, list):
return len(citations)
return 0
def ledger_urls(self) -> list[str]:
if self.ledger is None:
return []
entries = self.ledger.get("entries")
if not isinstance(entries, list):
return []
return [str(e.get("url")) for e in entries if isinstance(e, dict) and e.get("url")]
def _read_json(path: Path) -> dict[str, Any] | None:
try:
raw: object = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
return raw if isinstance(raw, dict) else None
def list_runs(base_dir: Path, owner_id: str) -> list[RunDiskRecord]:
"""Scan this owner's partition: state.json (+ report.json,
ledger.json where present). Dotfiles/tmp skipped defensively
(D-115); corrupt or stateless directories are skipped, never
fatal. Records are ordered most-recent first (created_at desc,
run_id as the stable tiebreaker)."""
runs_dir = base_dir / "runs" / owner_id
if not runs_dir.is_dir():
return []
records: list[RunDiskRecord] = []
for run_dir in sorted(runs_dir.iterdir()):
if not _is_scannable(run_dir):
continue
state = _read_json(run_dir / "state.json")
if state is None:
continue
records.append(
RunDiskRecord(
run_id=run_dir.name,
state=state,
report=_read_json(run_dir / "report.json"),
ledger=_read_json(run_dir / "ledger.json"),
)
)
records.sort(key=lambda r: (r.created_at, r.run_id), reverse=True)
return records
# ------------------------------------------------------- categorization (dumb)
_STOPWORDS = {
"the", "a", "an", "of", "in", "on", "and", "or", "to", "for", "is",
"are", "what", "which", "how", "do", "does", "why", "when", "with",
"that", "this", "these", "those", "it", "its", "by", "at", "be",
"can", "could", "should", "will", "into", "from", "about", "across",
}
_TOPIC_TOKEN_RE = re.compile(r"[a-z][a-z0-9-]*")
def topic_tokens(record: RunDiskRecord) -> list[str]:
"""Deterministic topic tokens: salient words of the plan
sub-questions (lowercased, deduped, order-preserving)."""
tokens: list[str] = []
for question in record.sub_questions:
for token in _TOPIC_TOKEN_RE.findall(question.lower()):
if token not in _STOPWORDS and token not in tokens:
tokens.append(token)
return tokens
def categorize(record: RunDiskRecord) -> str:
"""The folder id a report files under: methodology + topic token.
Deterministic + dumb (REQ-76): the methodology forms the folder
base and the first topic token of the plan names the sub-folder —
the same disk tree always categorizes identically. Runs without a
plan (pre-approval) fall back to the bare methodology slug; a run
with neither resolves to "research".
"""
slug = record.methodology_slug or "research"
tokens = topic_tokens(record)
if not tokens:
return slug
return f"{slug}-{tokens[0]}"
def _category_name(category: str) -> str:
"""Humanize a category id: 'deep-report-battery' -> 'Deep Report:
Battery'. The methodology slug (multi-hyphen, e.g.
'deep-report') is stripped as a unit before the topic token is
titled."""
for slug, name in _METHOD_NAMES.items():
prefix = f"{slug}-"
if category.startswith(prefix):
tail = category[len(prefix) :]
return f"{name}: {tail.replace('-', ' ').title()}" if tail else name
return category.replace("-", " ").title()
def _category_description(category: str) -> str:
return f"Auto-filed from {category.replace('-', ' ')} research."
def _summary(record: RunDiskRecord) -> ReportSummary:
generated_at = (
str(record.report.get("generated_at")) if record.report is not None else record.created_at
)
return ReportSummary(
id=f"rep-{record.run_id}",
runId=record.run_id,
title=record.report_title(),
question=record.question,
methodologySlug=record.methodology_slug,
category=categorize(record),
generatedAt=generated_at or datetime.now(UTC).isoformat(),
citationCount=record.citation_count(),
)
# ----------------------------------------------------------------- library
def library(base_dir: Path, owner_id: str, sort: str = "recent") -> dict[str, Any]:
"""GET /api/library payload: folders + ReportSummary[] (REQ-76).
Only DONE runs with a report are "stored reports" — a cancelled or
failed run produced nothing worth filing. ``sort`` is "recent"
(default, created_at desc — the scan order) or "title" (A-Z).
"""
if sort not in ("recent", "title"):
sort = "recent"
done = [r for r in list_runs(base_dir, owner_id) if r.is_done]
reports = [_summary(r) for r in done]
if sort == "title":
reports.sort(key=lambda summary: (summary.title.casefold(), summary.runId))
seen: dict[str, int] = {}
for summary in reports:
seen[summary.category] = seen.get(summary.category, 0) + 1
folders = [
ReportFolder(
id=category,
name=_category_name(category),
description=_category_description(category),
).payload()
for category in sorted(seen)
]
return {
"folders": folders,
"reports": [summary.payload() for summary in reports],
}
# ------------------------------------------------------------ connections
def _shared_urls(first: RunDiskRecord, second: RunDiskRecord) -> set[str]:
return set(first.ledger_urls()) & set(second.ledger_urls())
def connections(base_dir: Path, owner_id: str) -> list[dict[str, Any]]:
"""GET /api/library/connections payload (REQ-77): shared-source
edges (>=1 ledger URL overlap) + related-topic edges (same
methodology + shared category). Between the user's DONE runs
only; edges point older -> newer (the fixture convention).
"""
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
edges: list[ReportConnection] = []
for i, first in enumerate(done):
for second in done[i + 1 :]:
shared = _shared_urls(first, second)
if shared:
edges.append(
ReportConnection(
fromReportId=f"rep-{first.run_id}",
toReportId=f"rep-{second.run_id}",
kind="shared-sources",
label=f"{len(shared)} shared source{'s' if len(shared) != 1 else ''}",
)
)
elif categorize(first) == categorize(second):
edges.append(
ReportConnection(
fromReportId=f"rep-{first.run_id}",
toReportId=f"rep-{second.run_id}",
kind="related-topic",
label="Adjacent topic",
)
)
return [edge.payload() for edge in edges]
# -------------------------------------------------------------------- graph
def _report_finding(record: RunDiskRecord) -> str:
"""The node's first 'finding': the report's title (a real artifact
value — never fabricated prose)."""
return record.report_title()
def graph(base_dir: Path, owner_id: str) -> dict[str, Any]:
"""GET /api/graph payload (REQ-77): DONE runs as GraphNodes, the
same edges as connections() as GraphEdges."""
done = list(reversed([r for r in list_runs(base_dir, owner_id) if r.is_done]))
nodes = [
GraphNode(
id=record.run_id,
topic=record.report_title(),
linkedSources=sorted(set(record.ledger_urls())),
findings=[_report_finding(record)],
).payload()
for record in done
]
edges: list[GraphEdge] = []
for i, first in enumerate(done):
for second in done[i + 1 :]:
shared = _shared_urls(first, second)
if shared:
edges.append(GraphEdge(first.run_id, second.run_id, len(shared)))
elif categorize(first) == categorize(second):
edges.append(GraphEdge(first.run_id, second.run_id, 0))
return {"nodes": nodes, "edges": [edge.payload() for edge in edges]}
# -------------------------------------------------------------------- tasks
def tasks(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
"""GET /api/tasks payload (REQ-78, D-114): plan sub-questions as
InboxItems, ``done`` iff the source run reached DONE — post-sweep
CANCELLED runs surface ``open``, never answered."""
items: list[InboxItem] = []
for record in list_runs(base_dir, owner_id):
status = "done" if record.is_done else "open"
for idx, question in enumerate(record.sub_questions):
items.append(
InboxItem(
id=f"{record.run_id}#{idx}",
question=question,
sourceRunId=record.run_id,
status=status,
)
)
return [item.payload() for item in items]
# -------------------------------------------------------------- suggestions
def suggestions(base_dir: Path, owner_id: str) -> list[dict[str, str]]:
"""GET /api/suggestions payload (REQ-79): the OPEN inbox items
joined with their source run titles — derived from the user's own
runs only, never synthetic; empty for a fresh user."""
suggested: list[SuggestedQuery] = []
for record in list_runs(base_dir, owner_id):
if record.is_done:
continue
for idx, question in enumerate(record.sub_questions):
suggested.append(
SuggestedQuery(
id=f"sug-{record.run_id}#{idx}",
question=question,
sourceRunId=record.run_id,
sourceReportTitle=record.report_title(),
)
)
return [item.payload() for item in suggested]
__all__ = [
"GraphEdge",
"GraphNode",
"InboxItem",
"ReportConnection",
"ReportFolder",
"ReportSummary",
"RunDiskRecord",
"SuggestedQuery",
"categorize",
"connections",
"graph",
"library",
"list_runs",
"suggestions",
"tasks",
"topic_tokens",
]
+34
View File
@@ -162,6 +162,40 @@ class UserStore:
self._by_email[updated.email] = updated
self._persist_locked()
async def update_profile(
self,
user_id: str,
*,
display_name: str | None = None,
email: str | None = None,
) -> User:
"""Update display name and/or email (T-3.6 — GET/PUT /api/profile).
The email must remain unique across OTHER users (a taken
address raises UserError); the email index is maintained
atomically under the store lock. No deletion exists.
"""
with self._lock:
user = self._by_id.get(user_id)
if user is None:
raise UserError("unknown user")
new_email = user.email
if email is not None:
new_email = email.casefold()
other = self._by_email.get(new_email)
if other is not None and other.user_id != user_id:
raise UserError("email already registered")
new_display = display_name if display_name is not None else user.display_name
updated = user.model_copy(update={"email": new_email, "display_name": new_display})
if new_email != user.email:
old = self._by_email.get(user.email)
if old is not None and old.user_id == user_id:
del self._by_email[user.email]
self._by_id[user_id] = updated
self._by_email[new_email] = updated
self._persist_locked()
return updated
async def create_first_admin(self, *, email: str, password: str, display_name: str) -> User:
"""Atomically create the first user, who is admin by construction.
+28
View File
@@ -11,6 +11,7 @@ from researchx.adapters.types import AdapterError, ModelMessage
from researchx.adapters.url_guard import url_allowed
from researchx.config.dials import Dials
from researchx.config.run_config import RunConfig, resolve_dials
from researchx.engine.artifacts import build_evidence_artifact, build_report_artifact
from researchx.engine.events import EventBus, RunEvent
from researchx.engine.run_state import Plan, RunState
from researchx.engine.states import RunStateName
@@ -328,6 +329,33 @@ class RunActor:
)
checklist = export_checklist(ledger)
await self._storage.write_run_artifact(self._config.run_id, "checklist.md", checklist)
# Structured artifacts (T-3.1, D-113): report.json +
# evidence.json, written HERE — the only point where the
# per-actor EvidenceStore still exists (it dies with the actor;
# the disk read-models of D-114/D-115 can never rebuild it).
# Marker normalization [p-N] -> [^pN] happens at this
# persistence boundary ONLY (C3-10): report.md keeps the raw
# form the trust-loop regexes depend on.
evidence_artifact = build_evidence_artifact(
run_id=self._config.run_id, evidence=self._store
)
report_artifact = build_report_artifact(
run_id=self._config.run_id,
owner_id=self.state.owner_id,
question=self._config.question,
methodology_name=self._methodology.name,
methodology_slug=self._config.methodology_slug,
report_markdown=report,
ledger=ledger,
evidence=self._store,
generated_at=ledger.generated_at,
)
await self._storage.write_run_artifact(
self._config.run_id, "evidence.json", evidence_artifact.model_dump_json(indent=2)
)
await self._storage.write_run_artifact(
self._config.run_id, "report.json", report_artifact.model_dump_json(indent=2)
)
await self._checkpoint(RunStateName.DONE)
await self._publish("run_done", {"run_id": self._config.run_id})
+227
View File
@@ -0,0 +1,227 @@
"""Structured run artifacts (T-3.1, D-113): report.json + evidence.json.
Built at DONE inside ``RunActor._verify_phase`` — the ONLY point where
the per-actor ``EvidenceStore`` is still alive (it is GC'd with the
actor; D-115 disk read-models come later and can never see it).
**Marker normalization happens HERE ONLY (C3-10).** The writer emits
``[p-N]`` — the trust-loop regexes (``anchoring.py``,
``ledger.py``) and the actor's own ``_cited_urls`` depend on that raw
form, so ``report.md`` is never touched. The Reading Room parses
``[^pN]`` (``report-body.ts`` matches ``[A-Za-z0-9]+`` only —
``[^p-1]`` does NOT match), therefore report.json section bodies and
the id fields of both artifacts carry the normalized ``pN`` id form.
Both artifacts are versioned (``v: 1``) so later schema changes stay
detectable per file (D-106 old artifacts stay disposable/readable).
"""
import re
from datetime import datetime
from pydantic import BaseModel, Field
from researchx.evidence.store import EvidenceStore
from researchx.trust.ledger import CitationLedger
#: The writer's raw marker form — the ONLY pattern normalized here.
_MARKER_RE = re.compile(r"\[p-(\d+)\]")
#: ATX headings (H1..H6) — section delimiters for report.json.
_HEADING_RE = re.compile(r"^#{1,6}[ \t]+(.*)$", re.MULTILINE)
#: A lone H1 — the report title source.
_H1_RE = re.compile(r"^#[ \t]+(.*)$", re.MULTILINE)
#: Quote display cap — matches EvidenceStore's ``quote`` convention.
_QUOTE_MAX_CHARS = 200
def normalize_passage_id(passage_id: str) -> str:
"""``p-N`` -> ``pN`` (the id form report.json/evidence.json carry)."""
match = re.fullmatch(r"p-(\d+)", passage_id)
return f"p{match.group(1)}" if match else passage_id
def normalize_markers(text: str) -> str:
"""``[p-N]`` -> ``[^pN]`` — report.json section bodies ONLY."""
return _MARKER_RE.sub(lambda match: f"[^p{match.group(1)}]", text)
def extract_title(markdown: str, fallback: str) -> str:
"""First H1 of the report, else the methodology/question fallback."""
match = _H1_RE.search(markdown)
if match is not None:
return match.group(1).strip()
return fallback
def split_sections(markdown: str) -> list[tuple[str, str]]:
"""Split report.md into (heading, body) chunks on ATX headings.
Every ``^#{1,6}\\s`` heading starts a section and is included as
that section's heading (the H1 chunk included — the title is a
separate field and the frontend binding decides presentation).
Content before the first heading becomes a ``("", body)`` section
rather than being dropped — no content loss, no fabrication.
"""
sections: list[tuple[str, str]] = []
matches = list(_HEADING_RE.finditer(markdown))
if not matches:
body = markdown.strip()
if body:
sections.append(("", body))
return sections
preamble = markdown[: matches[0].start()].strip()
if preamble:
sections.append(("", preamble))
for index, match in enumerate(matches):
heading = match.group(1).strip()
start = match.end()
end = matches[index + 1].start() if index + 1 < len(matches) else len(markdown)
sections.append((heading, markdown[start:end].strip()))
return sections
class EvidencePassage(BaseModel):
"""One stored passage — id in the normalized ``pN`` form."""
passage_id: str
url: str
title: str
quote: str
text: str
class EvidenceArtifact(BaseModel):
"""evidence.json — the serialized EvidenceStore at DONE."""
v: int = 1
run_id: str
passages: list[EvidencePassage] = Field(default_factory=list)
class ReportSection(BaseModel):
heading: str
body: str
class ReportCitation(BaseModel):
"""A page-anchored citation — passage-level, quote included."""
passage_id: str # pN form
url: str
title: str
quote: str
health: str
authority_tier: str
class ReportArtifact(BaseModel):
"""report.json — the structured Reading-Room payload at DONE."""
v: int = 1
run_id: str
owner_id: str | None = None
title: str
question: str
methodology_slug: str
generated_at: datetime
sections: list[ReportSection] = Field(default_factory=list)
citations: list[ReportCitation] = Field(default_factory=list)
def _page_titles(evidence: EvidenceStore) -> dict[str, str]:
return {page.url: page.title for page in evidence.pages()}
def _clip(quote: str) -> str:
return quote[:_QUOTE_MAX_CHARS]
def build_evidence_artifact(*, run_id: str, evidence: EvidenceStore) -> EvidenceArtifact:
"""Serialize the full EvidenceStore — ids renumbered ``pN``.
Every passage the run collected, full text included: this is the
only persisted copy (the in-actor store dies at DONE).
"""
titles = _page_titles(evidence)
passages = [
EvidencePassage(
passage_id=normalize_passage_id(passage.passage_id),
url=passage.url,
title=titles.get(passage.url, ""),
quote=_clip(passage.quote),
text=passage.text,
)
for passage in evidence.writer_context()
]
return EvidenceArtifact(run_id=run_id, passages=passages)
def build_report_artifact(
*,
run_id: str,
owner_id: str | None,
question: str,
methodology_name: str,
methodology_slug: str,
report_markdown: str,
ledger: CitationLedger,
evidence: EvidenceStore,
generated_at: datetime,
) -> ReportArtifact:
"""Build report.json from the raw report.md + ledger + evidence.
Section bodies carry normalized ``[^pN]`` markers (C3-10); the raw
``report.md`` on disk is untouched. Citations are the ledger's cited
URL entries resolved to their stored passages — real quotes from
the evidence in hand, never fabricated.
"""
title = extract_title(report_markdown, fallback=f"{methodology_name} — {question}")
sections = [
ReportSection(heading=heading, body=normalize_markers(body))
for heading, body in split_sections(report_markdown)
]
titles = _page_titles(evidence)
citations: list[ReportCitation] = []
for entry in ledger.entries:
for passage_id in entry.passages:
passage = evidence.get(passage_id)
if passage is None:
continue
citations.append(
ReportCitation(
passage_id=normalize_passage_id(passage_id),
url=entry.url,
title=titles.get(entry.url, "") or entry.url,
quote=_clip(passage.quote),
health=entry.health,
authority_tier=entry.authority_tier,
)
)
return ReportArtifact(
run_id=run_id,
owner_id=owner_id,
title=title,
question=question,
methodology_slug=methodology_slug,
generated_at=generated_at,
sections=sections,
citations=citations,
)
__all__ = [
"EvidenceArtifact",
"EvidencePassage",
"ReportArtifact",
"ReportCitation",
"ReportSection",
"build_evidence_artifact",
"build_report_artifact",
"extract_title",
"normalize_markers",
"normalize_passage_id",
"split_sections",
]
+325
View File
@@ -0,0 +1,325 @@
"""Structured artifacts at DONE (T-3.1, D-113): report.json +
evidence.json — marker normalization at the persistence boundary ONLY
(C3-10: report.json bodies carry [^pN], report.md keeps raw [p-N])."""
import asyncio
import contextlib
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.local_storage import LocalStorageAdapter
from researchx.adapters.types import ChatResult
from researchx.engine.actor import EngineError, RunActor, make_run_config
from researchx.engine.artifacts import (
build_evidence_artifact,
build_report_artifact,
extract_title,
normalize_markers,
normalize_passage_id,
split_sections,
)
from researchx.engine.events import EventBus
from researchx.engine.states import RunStateName
from researchx.evidence.store import EvidenceStore
from researchx.methodologies import registry
from researchx.trust.ledger import build_ledger
from researchx.trust.liveness import LivenessStatus
_PLAN = json.dumps(
{
"sub_questions": ["methodology verification", "quote anchoring"],
"source_universe": ["web"],
"outline": ["intro", "findings"],
}
)
# Multi-section report with [p-N] markers on multiple lines.
_WRITER_REPORT = (
"# Battery Advances\n\n"
"The verification pass re-anchored every quoted claim [p-1].\n\n"
"## Findings\n\n"
"Methodology-first selection converts ad-hoc prompting [p-1] "
"into reproducible decisions [p-2].\n\n"
"## Conclusion\n\n"
"The anchored loop holds [p-2].\n"
)
def _canned() -> list[ChatResult]:
return [
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER_REPORT, model="fake-writer"),
]
def _storage(tmp_path: Path) -> LocalStorageAdapter:
return LocalStorageAdapter(base_dir=tmp_path, owner_id="abcd2345")
async def _run_to_done(tmp_path: Path) -> RunActor:
_run_id, config = make_run_config(
question="How do verification pipelines keep citations honest?",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
owner_id="abcd2345",
)
model = FakeModelAdapter(canned=_canned())
search = FakeSearchAdapter(CORPUS)
actor = RunActor(
run_config=config,
model=model,
search=search,
storage=_storage(tmp_path),
registry=registry,
event_bus=EventBus(),
)
actor.state.owner_id = "abcd2345"
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
if task.done():
break
await actor.approve()
await task
assert actor.state.state == RunStateName.DONE
return actor
async def _read_json(tmp_path: Path, name: str) -> dict[str, object]:
raw = await _storage(tmp_path).read_run_artifact("abcd2345", name)
return json.loads(raw) # type: ignore[no-any-return]
# ------------------------------------------------------------- unit helpers
def test_normalize_markers_converts_p_dash_form() -> None:
assert normalize_markers("claim [p-1] and [p-12]") == "claim [^p1] and [^p12]"
def test_normalize_passage_id_renumbers() -> None:
assert normalize_passage_id("p-1") == "p1"
assert normalize_passage_id("p-42") == "p42"
def test_split_sections_on_headings() -> None:
sections = split_sections("# Title\n\nintro body\n\n## A\n\na body\n\n### B\n\nb body")
assert sections == [
("Title", "intro body"),
("A", "a body"),
("B", "b body"),
]
def test_split_sections_preamble_and_no_headings() -> None:
assert split_sections("loose text only") == [("", "loose text only")]
assert split_sections("before\n\n# H\n\nafter") == [("", "before"), ("H", "after")]
def test_extract_title_prefers_first_h1() -> None:
assert extract_title("# First H1\n\n## Second\n", "fallback") == "First H1"
assert extract_title("no heading at all", "Deep Report — q?") == "Deep Report — q?"
# --------------------------------------------------------------- build paths
def _fixture_store() -> EvidenceStore:
store = EvidenceStore()
store.add_page(
"https://example.org/verification",
"Verification passes in research pipelines",
(
"The verification pass re-anchored every quoted claim to its stored "
"passage before the report was allowed to ship, so the audit trail "
"stays honest claim by claim."
),
)
return store
def test_build_evidence_artifact_renumbers_ids_and_keeps_text() -> None:
artifact = build_evidence_artifact(run_id="abcd2345", evidence=_fixture_store())
payload = json.loads(artifact.model_dump_json())
assert payload["v"] == 1
assert payload["run_id"] == "abcd2345"
assert len(payload["passages"]) == 1
passage = payload["passages"][0]
assert passage["passage_id"] == "p1" # p-1 -> p1
assert passage["url"] == "https://example.org/verification"
assert passage["title"] == "Verification passes in research pipelines"
assert passage["quote"] == passage["text"][:200]
assert "re-anchored" in passage["text"]
def test_build_report_artifact_sections_and_citations() -> None:
store = _fixture_store()
markdown = "# Report\n\nClaim [p-1] here.\n\n## Findings\n\nAnother [p-1] claim.\n"
ledger = build_ledger(
run_id="abcd2345",
report_text=markdown,
evidence=store,
liveness={"https://example.org/verification": LivenessStatus.LIVE},
visited_at=datetime.now(UTC),
)
artifact = build_report_artifact(
run_id="abcd2345",
owner_id="abcd2345",
question="q?",
methodology_name="Rapid Answer",
methodology_slug="rapid-answer",
report_markdown=markdown,
ledger=ledger,
evidence=store,
generated_at=ledger.generated_at,
)
payload = json.loads(artifact.model_dump_json())
assert payload["v"] == 1
assert payload["owner_id"] == "abcd2345"
assert payload["title"] == "Report"
assert payload["question"] == "q?"
assert payload["methodology_slug"] == "rapid-answer"
assert [s["heading"] for s in payload["sections"]] == ["Report", "Findings"]
# C3-10: normalized markers in section bodies ONLY.
assert "[^p1]" in payload["sections"][0]["body"]
assert "[p-1]" not in payload["sections"][0]["body"]
assert [c["passage_id"] for c in payload["citations"]] == ["p1"]
citation = payload["citations"][0]
assert citation["url"] == "https://example.org/verification"
assert citation["title"] == "Verification passes in research pipelines"
assert citation["quote"].startswith("The verification pass")
assert citation["health"] == "live"
assert citation["authority_tier"] == "reference"
def test_build_report_artifact_title_fallback_uses_methodology_name() -> None:
store = _fixture_store()
ledger = build_ledger(
run_id="abcd2345",
report_text="no heading [p-1]",
evidence=store,
liveness={},
visited_at=datetime.now(UTC),
)
artifact = build_report_artifact(
run_id="abcd2345",
owner_id=None,
question="what about X?",
methodology_name="Deep Report",
methodology_slug="deep-report",
report_markdown="no heading [p-1]",
ledger=ledger,
evidence=store,
generated_at=ledger.generated_at,
)
assert artifact.title == "Deep Report — what about X?"
# --------------------------------------------------- at-DONE persistence
async def test_done_writes_report_and_evidence_artifacts(tmp_path: Path) -> None:
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
evidence_payload = await _read_json(tmp_path, "evidence.json")
# report.json shape
assert report_payload["v"] == 1
assert report_payload["run_id"] == "abcd2345"
assert report_payload["owner_id"] == "abcd2345"
assert report_payload["title"] == "Battery Advances"
assert report_payload["methodology_slug"] == "rapid-answer"
assert "generated_at" in report_payload
assert [s["heading"] for s in report_payload["sections"]] == [ # type: ignore[call-overload]
"Battery Advances",
"Findings",
"Conclusion",
]
# evidence.json shape
assert evidence_payload["v"] == 1
assert evidence_payload["run_id"] == "abcd2345"
assert len(evidence_payload["passages"]) >= 2 # type: ignore[arg-type]
async def test_marker_normalization_at_boundary_only(tmp_path: Path) -> None:
"""C3-10 pinned end to end: report.json bodies carry [^pN]; the raw
report.md keeps [p-N] (the trust-loop regexes' form)."""
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
markdown = await _storage(tmp_path).read_run_artifact("abcd2345", "report.md")
sections = report_payload["sections"]
assert isinstance(sections, list) and sections
joined = "\n".join(str(s["body"]) for s in sections)
assert "[^p1]" in joined
assert "[p-1]" not in joined
assert "[^p2]" in joined
# The raw artifact is untouched — trust-loop regexes keep working.
assert "[p-1]" in markdown
assert "[^p1]" not in markdown
async def test_citations_carry_real_quotes_and_pn_ids(tmp_path: Path) -> None:
await _run_to_done(tmp_path)
report_payload = await _read_json(tmp_path, "report.json")
evidence_payload = await _read_json(tmp_path, "evidence.json")
citations = report_payload["citations"]
assert isinstance(citations, list) and citations
for citation in citations:
assert re.fullmatch(r"^p\d+$", str(citation["passage_id"]))
assert citation["url"].startswith("https://")
assert citation["quote"], "citation quote must be a real stored quote"
assert len(str(citation["quote"])) <= 200
assert citation["health"] in {"live", "stale", "dead"}
assert citation["authority_tier"]
# Evidence carries full texts: quote is the clipped head of text.
passages = evidence_payload["passages"]
assert isinstance(passages, list) and passages
evidence_by_id = {str(p["passage_id"]): p for p in passages}
for citation in citations:
passage = evidence_by_id[str(citation["passage_id"])]
assert str(passage["quote"]) == str(citation["quote"])
assert len(str(passage["text"])) >= len(str(passage["quote"]))
async def test_artifacts_only_written_at_done(tmp_path: Path) -> None:
"""A run cancelled at AWAITING_APPROVAL leaves no structured
artifacts — the write point is _verify_phase, nothing earlier."""
_run_id, config = make_run_config(
question="q",
context="",
methodology_slug="rapid-answer",
overrides={},
registry=registry,
run_id="abcd2345",
owner_id="abcd2345",
)
actor = RunActor(
run_config=config,
model=FakeModelAdapter(canned=_canned()),
search=FakeSearchAdapter(CORPUS),
storage=_storage(tmp_path),
registry=registry,
event_bus=EventBus(),
)
task = asyncio.create_task(actor.run())
while actor.state.state != RunStateName.AWAITING_APPROVAL:
await asyncio.sleep(0.01)
actor.cancel()
await actor.approve()
with contextlib.suppress(asyncio.CancelledError, EngineError):
await task
run_dir = tmp_path / "runs" / "abcd2345" / "abcd2345"
assert not (run_dir / "report.json").exists()
assert not (run_dir / "evidence.json").exists()
+687
View File
@@ -0,0 +1,687 @@
"""Live-surface route tests (T-3.3, REQ-76..82).
Every new/reworked route over a fixture disk tree: report/ledger
disk-read with pre-artifact DONE fallback, library/connections/graph/
tasks/suggestions, refine (200 on fakes, honest 503 on adapter
failure), profile round-trip, and the FRESH-APP-OVER-EXISTING-DATA-
ROOT test (P-8 full closure — every read route correct against a
second create_app with zero in-memory actors).
"""
import asyncio
import json
from pathlib import Path
import httpx
from researchx.adapters.fakes import CORPUS, FakeModelAdapter, FakeSearchAdapter
from researchx.adapters.types import AdapterError, ChatResult, ModelMessage
from researchx.api.app import create_app
from researchx.auth.guard import CSRF_HEADER
_PLAN = json.dumps(
{"sub_questions": ["verification passes"], "source_universe": ["web"], "outline": ["intro"]}
)
_WRITER = "# Research Memo\n\nClaim [p-1] and claim [p-2].\n"
_HEADERS = {CSRF_HEADER: "1"}
_DEV = "dev00000" # the synthetic dev admin (auth_enabled=False harness)
def _state(run_id: str, question: str, state: str, created_at: str, plan: dict | None = None) -> dict:
return {
"run_id": run_id,
"question": question,
"context": "",
"methodology_slug": "deep-report",
"state": state,
"owner_id": _DEV,
"plan": plan if plan is not None else {
"sub_questions": ["battery density question"],
"source_universe": ["web"],
"outline": ["intro"],
},
"depth_cursor": 0,
"depth_target": 1,
"counters": {},
"probe": None,
"created_at": created_at,
"updated_at": created_at,
}
def _seed_run(
tmp_path: Path,
run_id: str,
*,
state: str,
question: str = "What about battery research?",
created_at: str = "2026-09-20T10:00:00Z",
plan: dict | None = None,
report_markdown: str | None = None,
report_json: dict | None = None,
evidence_json: dict | None = None,
ledger_json: dict | None = None,
owner: str = _DEV,
) -> None:
run_dir = tmp_path / "runs" / owner / run_id
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "state.json").write_text(
json.dumps(_state(run_id, question, state, created_at, plan)), encoding="utf-8"
)
if report_markdown is not None:
(run_dir / "report.md").write_text(report_markdown, encoding="utf-8")
if report_json is not None:
(run_dir / "report.json").write_text(json.dumps(report_json), encoding="utf-8")
if evidence_json is not None:
(run_dir / "evidence.json").write_text(json.dumps(evidence_json), encoding="utf-8")
if ledger_json is not None:
(run_dir / "ledger.json").write_text(json.dumps(ledger_json), encoding="utf-8")
_REPORT_JSON = {
"v": 1,
"run_id": "runaaaa1",
"owner_id": _DEV,
"title": "Battery Advances",
"question": "What about battery research?",
"methodology_slug": "deep-report",
"generated_at": "2026-09-20T10:05:00Z",
"sections": [
{"heading": "Research Memo", "body": "The verified core finding [^p1]."}
],
"citations": [
{
"passage_id": "p1",
"url": "https://example.org/x",
"title": "Verification passes",
"quote": "The verified core finding.",
"health": "live",
"authority_tier": "reference",
}
],
}
_EVIDENCE_JSON = {
"v": 1,
"run_id": "runaaaa1",
"passages": [
{
"passage_id": "p1",
"url": "https://example.org/x",
"title": "Verification passes",
"quote": "The verified core finding.",
"text": "The verified core finding in full, stored at DONE.",
}
],
}
_LEDGER_JSON = {
"run_id": "runaaaa1",
"generated_at": "2026-09-20T10:05:00Z",
"entries": [
{
"url": "https://example.org/x",
"visited_at": "2026-09-20T10:04:00Z",
"passages": ["p-1"],
"claims_supported": 2,
"health": "live",
"authority_tier": "reference",
}
],
"checklist": {"run_id": "runaaaa1", "citations_checked": 1},
}
def _seed_tree(tmp_path: Path) -> Path:
"""The route fixture tree under the dev owner:
- runaaaa1: DONE with report.md + report.json + evidence.json + ledger
- runbbbb1: DONE pre-artifact (report.md only — no report.json)
- runcccc1: non-terminal (SEARCHING) — swept to CANCELLED by create_app
"""
_seed_run(
tmp_path,
"runaaaa1",
state="DONE",
report_markdown="# Research Memo\n\nThe verified core finding [p-1].\n",
report_json=_REPORT_JSON,
evidence_json=_EVIDENCE_JSON,
ledger_json=_LEDGER_JSON,
)
_seed_run(
tmp_path,
"runbbbb1",
state="DONE",
question="A pre-artifact done run?",
created_at="2026-09-19T10:00:00Z",
report_markdown="# Old Report\n\nRaw [p-1] markers only.\n",
)
_seed_run(
tmp_path,
"runcccc1",
state="SEARCHING",
question="Swept mid-flight question?",
created_at="2026-09-21T10:00:00Z",
)
return tmp_path
def _fake_app(tmp_path: Path, canned: list[ChatResult] | None = None) -> object:
return create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=canned or [ChatResult(content=_PLAN, model="p")]),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async def _client(app: object) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=httpx.ASGITransport(app=app), # type: ignore[arg-type]
base_url="http://testserver",
)
# --------------------------------------------------------------- report route
async def test_report_route_disk_read_returns_full_payload(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runaaaa1/report")
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "runaaaa1"
assert "[p-1]" in body["markdown"] # raw form retained (back-compat)
assert body["report"] == _REPORT_JSON
assert body["evidence"] == _EVIDENCE_JSON
async def test_report_route_pre_artifact_done_fallback(tmp_path: Path) -> None:
"""A DONE run from before T-3.1 (report.md only): markdown served,
null report/evidence — never a 500 (D-106)."""
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runbbbb1/report")
assert response.status_code == 200
body = response.json()
assert body["run_id"] == "runbbbb1"
assert "Raw [p-1] markers" in body["markdown"]
assert body["report"] is None
assert body["evidence"] is None
async def test_report_route_missing_run_404(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
assert (await client.get("/api/runs/zzzz9999/report")).status_code == 404
async def test_ledger_route_disk_read(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs/runaaaa1/ledger")
assert response.status_code == 200
assert response.json() == _LEDGER_JSON
assert (await client.get("/api/runs/zzzz9999/ledger")).status_code == 404
# ------------------------------------------------------------ run list route
async def test_runs_route_lists_from_disk_recent_first(tmp_path: Path) -> None:
"""GET /api/runs from disk state files; the SEARCHING fixture is
swept to CANCELLED at create_app — it surfaces cancelled, NOT open
(post-sweep honesty)."""
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/runs")
assert response.status_code == 200
runs = response.json()
assert [run["id"] for run in runs] == ["runcccc1", "runaaaa1", "runbbbb1"]
statuses = {run["id"]: run["status"] for run in runs}
assert statuses["runcccc1"] == "cancelled" # swept, honest
assert statuses["runaaaa1"] == "done"
assert statuses["runbbbb1"] == "done"
first = runs[1]
assert first["question"] == "What about battery research?"
assert first["methodology"] == "deep-report"
assert first["plan"]["sub_questions"] == ["battery density question"]
# ----------------------------------------------------------- library routes
async def test_library_route(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
response = await client.get("/api/library")
assert response.status_code == 200
body = response.json()
assert [folder["id"] for folder in body["folders"]] == ["deep-report-battery"]
assert [report["runId"] for report in body["reports"]] == ["runaaaa1", "runbbbb1"]
first = body["reports"][0]
assert first["title"] == "Battery Advances"
assert first["citationCount"] == 1
assert first["category"] == "deep-report-battery"
# title sort: "Battery Advances" < "Deep Report — A pre-artifact..."
titled = await client.get("/api/library?sort=title")
assert [r["runId"] for r in titled.json()["reports"]] == ["runaaaa1", "runbbbb1"]
async def test_connections_graph_tasks_suggestions_routes(tmp_path: Path) -> None:
_seed_tree(tmp_path)
async with await _client(_fake_app(tmp_path)) as client:
connections = await client.get("/api/library/connections")
assert connections.status_code == 200
# runaaaa1 has a ledger; runbbbb1 has none -> no shared URL, and
# same methodology+category -> related-topic edge.
assert connections.json() == [
{
"fromReportId": "rep-runbbbb1",
"toReportId": "rep-runaaaa1",
"kind": "related-topic",
"label": "Adjacent topic",
}
]
graph = await client.get("/api/graph")
assert graph.status_code == 200
payload = graph.json()
assert {node["id"] for node in payload["nodes"]} == {"runaaaa1", "runbbbb1"}
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
assert node["topic"] == "Battery Advances"
assert node["linkedSources"] == ["https://example.org/x"]
assert payload["edges"] == [{"fromId": "runbbbb1", "toId": "runaaaa1", "sharedSourceCount": 0}]
tasks = await client.get("/api/tasks")
assert tasks.status_code == 200
items = {item["id"]: item for item in tasks.json()}
assert items["runaaaa1#0"]["status"] == "done"
assert items["runbbbb1#0"]["status"] == "done"
# The swept CANCELLED run's question stays OPEN (D-114).
assert items["runcccc1#0"]["status"] == "open"
assert items["runcccc1#0"]["question"] == "battery density question"
suggestions = await client.get("/api/suggestions")
assert suggestions.status_code == 200
assert suggestions.json() == [
{
"id": "sug-runcccc1#0",
"question": "battery density question",
"sourceRunId": "runcccc1",
"sourceReportTitle": "Deep Report — Swept mid-flight question?",
}
]
async def test_fresh_user_empty_states(tmp_path: Path) -> None:
"""Honest empty states: no synthetic content anywhere."""
async with await _client(_fake_app(tmp_path)) as client:
assert (await client.get("/api/library")).json() == {"folders": [], "reports": []}
assert (await client.get("/api/library/connections")).json() == []
assert (await client.get("/api/graph")).json() == {"nodes": [], "edges": []}
assert (await client.get("/api/tasks")).json() == []
assert (await client.get("/api/suggestions")).json() == []
assert (await client.get("/api/runs")).json() == []
# ---------------------------------------------------------------- refine
class _RefineModel:
"""Fake model for the refine exchange: answers the questions prompt,
then the compose prompt; a second instance answers with an
AdapterError to pin the honest 503."""
def __init__(self, fail: bool = False) -> None:
self._fail = fail
self._calls = 0
async def chat(
self,
messages: list[ModelMessage],
*,
model: str,
temperature: float = 0.2,
json_mode: bool = False,
) -> ChatResult:
del messages, temperature, model
if self._fail:
raise AdapterError("no local model reachable")
self._calls += 1
if self._calls == 1:
return ChatResult(
content=json.dumps(
{
"questions": [
"What scope matters most?",
"What decision will this inform?",
"How deep should the answer go?",
]
}
),
model="fake-refine",
)
return ChatResult(
content=json.dumps(
{
"question": "How do batteries work — focusing on density?",
"context": "Feeds a supplier decision. Depth: executive summary.",
}
),
model="fake-refine",
)
async def list_models(self) -> list[str]:
return ["fake-refine"]
def roles(self) -> object:
from researchx.adapters.model import ModelRoles
return ModelRoles(planner="fake-refine", writer="fake-refine")
async def test_refine_questions_and_compose(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(app) as client:
asked = await client.post("/api/refine/questions", json={"draft": "how do batteries work?"})
assert asked.status_code == 200
assert asked.json() == {
"questions": [
"What scope matters most?",
"What decision will this inform?",
"How deep should the answer go?",
]
}
composed = await client.post(
"/api/refine/compose",
json={"draft": "how do batteries work?", "answers": ["density", "supplier choice"]},
)
assert composed.status_code == 200
assert composed.json() == {
"question": "How do batteries work — focusing on density?",
"context": "Feeds a supplier decision. Depth: executive summary.",
}
async def test_refine_honest_503_never_hang(tmp_path: Path) -> None:
"""A keyless user with no reachable endpoint gets an honest 503 —
never a hang, never a silent success."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(fail=True),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(app) as client:
asked = await asyncio.wait_for(
client.post("/api/refine/questions", json={"draft": "q"}), timeout=5.0
)
assert asked.status_code == 503
composed = await asyncio.wait_for(
client.post(
"/api/refine/compose", json={"draft": "q", "answers": ["a"]}, headers=_HEADERS
),
timeout=5.0,
)
assert composed.status_code == 503
async def test_refine_requires_csrf_header_when_authed(tmp_path: Path) -> None:
"""Refine POSTs are mutations: the CSRF header applies (T-3.6)."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# register + login (both with the header)
await client.post(
"/api/auth/register",
json={"email": "a@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await client.post(
"/api/auth/login", json={"email": "a@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
no_header = await client.post("/api/refine/questions", json={"draft": "q"})
assert no_header.status_code == 403
with_header = await client.post("/api/refine/questions", json={"draft": "q"}, headers=_HEADERS)
assert with_header.status_code == 200
# ---------------------------------------------------------------- profile
async def test_profile_round_trip(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
await client.post(
"/api/auth/register",
json={"email": "first@example.org", "password": "long-enough-pass", "display_name": "First"},
headers=_HEADERS,
)
await client.post(
"/api/auth/login",
json={"email": "first@example.org", "password": "long-enough-pass"},
headers=_HEADERS,
)
got = await client.get("/api/profile")
assert got.status_code == 200
assert got.json() == {
"user_id": got.json()["user_id"],
"display_name": "First",
"email": "first@example.org",
}
put = await client.put(
"/api/profile", json={"display_name": "Renamed", "email": "new@example.org"}, headers=_HEADERS
)
assert put.status_code == 200
assert put.json()["display_name"] == "Renamed"
assert put.json()["email"] == "new@example.org"
again = await client.get("/api/profile")
assert again.json() == put.json()
async def test_profile_email_unique_across_users(tmp_path: Path) -> None:
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await admin.post(
"/api/auth/register",
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await admin.post(
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
created = await admin.post(
"/api/admin/users",
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
headers=_HEADERS,
)
assert created.status_code == 201
# admin tries to steal the second user's email
taken = await admin.put(
"/api/profile", json={"email": "second@example.org"}, headers=_HEADERS
)
assert taken.status_code == 409
# ---------------------------------------------- cross-user isolation (authed)
async def test_cross_user_404_on_run_scoped_routes(tmp_path: Path) -> None:
"""Owner-scoped disk reads: user 2 requesting user 1's run gets a
404 (no existence leak) — the owner dir simply doesn't exist under
user 2's storage view."""
app = create_app(
storage_dir=tmp_path,
model_adapter=_RefineModel(),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=True,
data_dir=tmp_path,
)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as admin:
await admin.post(
"/api/auth/register",
json={"email": "admin@example.org", "password": "long-enough-pass", "display_name": "A"},
headers=_HEADERS,
)
await admin.post(
"/api/auth/login", json={"email": "admin@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
admin_id = (await admin.get("/api/auth/me")).json()["user_id"]
_seed_run(
tmp_path,
"runaaaa1",
state="DONE",
report_markdown="# Mine\n\n[p-1]\n",
report_json=_REPORT_JSON,
evidence_json=_EVIDENCE_JSON,
ledger_json=_LEDGER_JSON,
owner=admin_id,
)
await admin.post(
"/api/admin/users",
json={"email": "second@example.org", "password": "long-enough-pass", "role": "user"},
headers=_HEADERS,
)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as second:
await second.post(
"/api/auth/login", json={"email": "second@example.org", "password": "long-enough-pass"}, headers=_HEADERS
)
assert (await second.get("/api/runs/runaaaa1/report")).status_code == 404
assert (await second.get("/api/runs/runaaaa1/ledger")).status_code == 404
# The owner-scoped collections are simply empty for user 2.
assert (await second.get("/api/runs")).json() == []
assert (await second.get("/api/library")).json() == {"folders": [], "reports": []}
assert (await second.get("/api/tasks")).json() == []
assert (await second.get("/api/suggestions")).json() == []
# ------------------------------------- P-8 full closure: fresh app, same root
async def test_full_run_over_http_writes_and_serves_artifacts(tmp_path: Path) -> None:
"""End-to-end marker contract through the API: a run driven to DONE
persists report.json/evidence.json (T-3.1), and the reworked report
route serves normalized [^pN] bodies while report.md (via the
markdown field) keeps raw [p-N]."""
app = _fake_app(
tmp_path,
canned=[
ChatResult(content=_PLAN, model="fake-planner"),
ChatResult(content=_WRITER, model="fake-writer"),
],
)
async with await _client(app) as client:
created = await client.post(
"/api/runs", json={"question": "How do engines run?", "methodology": "rapid-answer"}
)
assert created.status_code == 201
run_id = created.json()["id"]
for _ in range(400): # await AWAITING_APPROVAL
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "awaiting_approval":
break
await asyncio.sleep(0.01)
await client.post(f"/api/runs/{run_id}/approve")
for _ in range(400): # await DONE
status = (await client.get(f"/api/runs/{run_id}")).json()["status"]
if status == "done":
break
await asyncio.sleep(0.01)
assert status == "done"
report = (await client.get(f"/api/runs/{run_id}/report")).json()
assert report["report"] is not None
assert report["evidence"] is not None
# C3-10 through the API: bodies normalized, markdown raw.
joined = "\n".join(str(s["body"]) for s in report["report"]["sections"])
assert "[^p1]" in joined or "[^p2]" in joined
assert "[p-1]" not in joined
assert "[p-1]" in report["markdown"]
# citations + evidence ids are pN-form with real quotes.
for citation in report["report"]["citations"]:
assert citation["passage_id"].startswith("p")
assert "-" not in citation["passage_id"]
assert citation["quote"]
passages = report["evidence"]["passages"]
assert passages, "evidence must carry the full stored passages"
assert all(len(str(p["text"])) >= len(str(p["quote"])) for p in passages)
# The disk read-models see the run too (library/tasks derive).
library = (await client.get("/api/library")).json()
assert [r["runId"] for r in library["reports"]] == [run_id]
tasks = (await client.get("/api/tasks")).json()
assert all(item["status"] == "done" for item in tasks)
async def test_fresh_app_over_existing_data_root(tmp_path: Path) -> None:
"""P-8 full closure: a SECOND create_app over the same data root —
zero in-memory actors/registry — every read route still correct.
(The registry-independence proof; the first app's actor state is
unreachable by construction here.)"""
_seed_tree(tmp_path)
first = _fake_app(tmp_path)
del first # the first app instance is discarded; nothing carries over
second = create_app(
storage_dir=tmp_path,
model_adapter=FakeModelAdapter(canned=[ChatResult(content=_PLAN, model="p")]),
search_adapter=FakeSearchAdapter(CORPUS),
auth_enabled=False,
data_dir=tmp_path,
)
async with await _client(second) as client:
# report: full payload, disk-read
report = (await client.get("/api/runs/runaaaa1/report")).json()
assert report["report"] == _REPORT_JSON
assert report["evidence"] == _EVIDENCE_JSON
assert "[p-1]" in report["markdown"]
# pre-artifact fallback survives the restart too
legacy = (await client.get("/api/runs/runbbbb1/report")).json()
assert legacy["report"] is None and legacy["markdown"]
# ledger
assert (await client.get("/api/runs/runaaaa1/ledger")).json() == _LEDGER_JSON
# run list: swept CANCELLED is cancelled, not open
statuses = {run["id"]: run["status"] for run in (await client.get("/api/runs")).json()}
assert statuses["runcccc1"] == "cancelled"
# library + connections + graph + tasks + suggestions
library = (await client.get("/api/library")).json()
assert [r["runId"] for r in library["reports"]] == ["runaaaa1", "runbbbb1"]
assert (await client.get("/api/library/connections")).status_code == 200
assert (await client.get("/api/graph")).status_code == 200
tasks = {item["id"]: item for item in (await client.get("/api/tasks")).json()}
assert tasks["runcccc1#0"]["status"] == "open"
assert (await client.get("/api/suggestions")).status_code == 200
# profile (dev harness GET works with zero stored runs)
assert (await client.get("/api/profile")).status_code == 200
+508
View File
@@ -0,0 +1,508 @@
"""Read-model unit tests (T-3.2, D-114/D-115 — REQ-76..79).
A fixture disk tree — two users; DONE, CANCELLED, FAILED runs; a
dotfile and a .tmp leftover — asserting categorization, sorting,
shared-source/topic edges, tasks answered-iff DONE, suggestions
(never synthetic, empty for fresh users), dotfile/tmp skip, and
cross-user isolation at the read-model level.
"""
import json
from pathlib import Path
from researchx.api.read_models import (
categorize,
connections,
graph,
library,
list_runs,
suggestions,
tasks,
topic_tokens,
)
_U1 = "aaaa1111"
_U2 = "bbbb2222"
_PLAN_A = {
"sub_questions": ["battery energy density gains", "solid-state pilot production"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_B = {
"sub_questions": ["battery safety testing standards"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_CANCELLED = {
"sub_questions": ["abandoned open question"],
"source_universe": ["web"],
"outline": ["intro"],
}
_PLAN_FAILED = {"sub_questions": ["failed run question"], "source_universe": ["web"], "outline": []}
_PLAN_INFLIGHT = {"sub_questions": ["still running question"], "source_universe": ["web"], "outline": []}
_REPORT_A = {
"v": 1,
"run_id": "runaaaa1",
"owner_id": _U1,
"title": "Battery Advances",
"question": "What are the latest advances in solid-state batteries?",
"methodology_slug": "deep-report",
"generated_at": "2026-09-20T14:32:00Z",
"sections": [{"heading": "Report", "body": "Claim [^p1]."}],
"citations": [
{"passage_id": "p1", "url": "https://example.org/x", "title": "X", "quote": "q", "health": "live", "authority_tier": "reference"}
],
}
_LEDGER_A = {
"run_id": "runaaaa1",
"generated_at": "2026-09-20T14:32:00Z",
"entries": [
{"url": "https://example.org/x", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-1"], "claims_supported": 1, "health": "live", "authority_tier": "reference"},
{"url": "https://example.org/y", "visited_at": "2026-09-20T14:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "live", "authority_tier": "general"},
],
"checklist": {},
}
_LEDGER_B = {
"run_id": "runbbbb1",
"generated_at": "2026-09-22T09:14:00Z",
"entries": [
{"url": "https://example.org/x", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-1"], "claims_supported": 2, "health": "live", "authority_tier": "reference"},
{"url": "https://example.org/z", "visited_at": "2026-09-22T09:00:00Z", "passages": ["p-2"], "claims_supported": 1, "health": "stale", "authority_tier": "general"},
],
"checklist": {},
}
def _state(
run_id: str,
question: str,
slug: str,
state: str,
plan: dict | None,
created_at: str,
) -> dict:
return {
"run_id": run_id,
"question": question,
"context": "",
"methodology_slug": slug,
"state": state,
"owner_id": _U1,
"plan": plan,
"depth_cursor": 0,
"depth_target": 1,
"counters": {},
"probe": None,
"created_at": created_at,
"updated_at": created_at,
}
def _write_run(
base: Path,
owner: str,
run_id: str,
state: dict,
report: dict | None = None,
ledger: dict | None = None,
) -> None:
run_dir = base / "runs" / owner / run_id
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "state.json").write_text(json.dumps(state), encoding="utf-8")
if report is not None:
(run_dir / "report.json").write_text(json.dumps(report), encoding="utf-8")
if ledger is not None:
(run_dir / "ledger.json").write_text(json.dumps(ledger), encoding="utf-8")
def _tree(tmp_path: Path) -> Path:
"""The fixture tree: user 1 has DONE A (deep-report, battery), DONE
B (rapid-answer, battery safety), a CANCELLED run, and a FAILED
run; user 2 has one DONE run invisible to user 1."""
base = tmp_path
_write_run(
base,
_U1,
"runaaaa1",
_state(
"runaaaa1",
"What are the latest advances in solid-state batteries?",
"deep-report",
"DONE",
_PLAN_A,
"2026-09-20T14:32:00Z",
),
report=_REPORT_A,
ledger=_LEDGER_A,
)
_write_run(
base,
_U1,
"runbbbb1",
_state(
"runbbbb1",
"How do battery safety standards evolve?",
"rapid-answer",
"DONE",
_PLAN_B,
"2026-09-22T09:14:00Z",
),
ledger=_LEDGER_B,
)
_write_run(
base,
_U1,
"runcanc1",
_state(
"runcanc1",
"abandoned mid-flight",
"deep-report",
"CANCELLED",
_PLAN_CANCELLED,
"2026-09-21T10:00:00Z",
),
)
_write_run(
base,
_U1,
"runfail1",
_state(
"runfail1",
"it broke",
"rapid-answer",
"FAILED",
_PLAN_FAILED,
"2026-09-23T10:00:00Z",
),
)
# Defensive-scan fodder (D-115): dotfile + tmp leftovers + junk.
(base / "runs" / _U1 / ".hidden").mkdir(parents=True)
(base / "runs" / _U1 / "runbbbb1.tmp").mkdir(parents=True)
(base / "runs" / _U1 / "orphan001").mkdir(parents=True) # no state.json: skipped
# An in-flight run (raw, pre-sweep — the read-model sees the state
# as written; the sweep happens at create_app in the route layer).
_write_run(
base,
_U1,
"runinflg1",
_state(
"runinflg1",
"still running",
"deep-report",
"SEARCHING",
_PLAN_INFLIGHT,
"2026-09-25T08:00:00Z",
),
)
# The other user's run: invisible at every read-model level.
_write_run(
base,
_U2,
"runcccc1",
_state(
"runcccc1",
"user two's private research",
"deep-report",
"DONE",
_PLAN_B,
"2026-09-24T10:00:00Z",
),
ledger=_LEDGER_A,
)
return base
# ------------------------------------------------------------------ scanning
def test_list_runs_scans_owner_partition_only(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = [record.run_id for record in list_runs(base, _U1)]
# CANCELLED + FAILED + in-flight included; dotfile/tmp/orphan
# skipped; user 2's run absent.
assert set(ids) == {"runaaaa1", "runbbbb1", "runcanc1", "runfail1", "runinflg1"}
def test_list_runs_orders_most_recent_first(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = [record.run_id for record in list_runs(base, _U1)]
assert ids[0] == "runinflg1" # 2026-09-25
assert ids[-1] == "runaaaa1" # 2026-09-20
def test_list_runs_skips_dotfiles_and_tmp(tmp_path: Path) -> None:
base = _tree(tmp_path)
ids = {record.run_id for record in list_runs(base, _U1)}
assert ".hidden" not in ids
assert "runbbbb1.tmp" not in ids
def test_list_runs_empty_for_fresh_user(tmp_path: Path) -> None:
base = _tree(tmp_path)
assert list_runs(base, "cccc3333") == []
# ------------------------------------------------------------ categorization
def test_categorize_is_methodology_plus_first_topic_token() -> None:
record = list_runs # placeholder to keep import honest
del record
from researchx.api.read_models import RunDiskRecord
run = RunDiskRecord(
run_id="runaaaa1",
state={
"run_id": "runaaaa1",
"question": "q",
"methodology_slug": "deep-report",
"state": "DONE",
"plan": _PLAN_A,
"created_at": "2026-09-20T14:32:00Z",
},
)
assert categorize(run) == "deep-report-battery"
def test_topic_tokens_skip_stopwords_and_dedupe() -> None:
from researchx.api.read_models import RunDiskRecord
run = RunDiskRecord(
run_id="r",
state={
"run_id": "r",
"question": "q",
"methodology_slug": "rapid-answer",
"state": "DONE",
"plan": {"sub_questions": ["The what of battery battery gains"]},
"created_at": "2026-09-20T14:32:00Z",
},
)
assert topic_tokens(run) == ["battery", "gains"]
# ------------------------------------------------------------------ library
def test_library_folders_and_summaries(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, _U1)
# Folders from the two DONE runs' categories, sorted by id.
assert [folder["id"] for folder in payload["folders"]] == [
"deep-report-battery",
"rapid-answer-battery",
]
assert payload["folders"][0]["name"] == "Deep Report: Battery"
# Only DONE runs are filed (cancelled/failed produce nothing).
assert {report["runId"] for report in payload["reports"]} == {"runaaaa1", "runbbbb1"}
first = next(r for r in payload["reports"] if r["runId"] == "runaaaa1")
assert first["id"] == "rep-runaaaa1"
assert first["title"] == "Battery Advances"
assert first["methodologySlug"] == "deep-report"
assert first["category"] == "deep-report-battery"
assert first["generatedAt"] == "2026-09-20T14:32:00Z"
assert first["citationCount"] == 1
# Pre-artifact DONE run: falls back to state created_at + methodology title.
second = next(r for r in payload["reports"] if r["runId"] == "runbbbb1")
assert second["generatedAt"] == "2026-09-22T09:14:00Z"
assert second["title"] == "Rapid Answer — How do battery safety standards evolve?"
def test_library_sort_recent_and_title(tmp_path: Path) -> None:
base = _tree(tmp_path)
recent = [r["runId"] for r in library(base, _U1, sort="recent")["reports"]]
title = [r["runId"] for r in library(base, _U1, sort="title")["reports"]]
# recent: newest first (created_at desc).
assert recent == ["runbbbb1", "runaaaa1"]
# title: "Battery Advances" < "Rapid Answer — ..." A-Z.
assert title == ["runaaaa1", "runbbbb1"]
# unknown sort key falls back to recent.
assert library(base, _U1, sort="bogus")["reports"][0]["runId"] == "runbbbb1"
def test_library_fresh_user_is_empty(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, "cccc3333")
assert payload == {"folders": [], "reports": []}
def test_library_cross_user_isolation(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = library(base, _U1)
assert all(report["runId"] != "runcccc1" for report in payload["reports"])
# ------------------------------------------------------------- connections
def test_connections_shared_source_edge(tmp_path: Path) -> None:
base = _tree(tmp_path)
edges = connections(base, _U1)
shared = [e for e in edges if e["kind"] == "shared-sources"]
# runaaaa1 + runbbbb1 share https://example.org/x (>=1 URL overlap).
assert shared == [
{
"fromReportId": "rep-runaaaa1",
"toReportId": "rep-runbbbb1",
"kind": "shared-sources",
"label": "1 shared source",
}
]
def test_connections_related_topic_edge_when_no_shared_urls(tmp_path: Path) -> None:
base = _tree(tmp_path)
# Strip the shared URL: the two DONE runs then connect only via
# category (same methodology + first topic token) — no, they have
# DIFFERENT methodologies. Instead verify the pairing rule: with no
# shared URL and differing categories there is NO edge.
other = base / "runs" / _U1 / "runbbbb1" / "ledger.json"
payload = json.loads(other.read_text(encoding="utf-8"))
payload["entries"] = [e for e in payload["entries"] if e["url"] != "https://example.org/x"]
other.write_text(json.dumps(payload), encoding="utf-8")
edges = connections(base, _U1)
# deep-report-battery vs rapid-answer-battery: different methodology
# folders -> not related-topic, and no shared source -> no edge.
assert edges == []
def test_connections_related_topic_edge_on_same_category(tmp_path: Path) -> None:
base = tmp_path / "iso"
_write_run(
base,
_U1,
"runx0011",
_state("runx0011", "battery density", "deep-report", "DONE", _PLAN_A, "2026-09-20T10:00:00Z"),
ledger={"entries": [{"url": "https://example.org/x"}], "checklist": {}},
)
_write_run(
base,
_U1,
"runx0022",
_state("runx0022", "battery pilots", "deep-report", "DONE", _PLAN_A, "2026-09-21T10:00:00Z"),
ledger={"entries": [{"url": "https://example.org/q"}], "checklist": {}},
)
edges = connections(base, _U1)
assert edges == [
{
"fromReportId": "rep-runx0011",
"toReportId": "rep-runx0022",
"kind": "related-topic",
"label": "Adjacent topic",
}
]
def test_connections_cross_user_isolation(tmp_path: Path) -> None:
base = _tree(tmp_path)
edges = connections(base, _U1)
for edge in edges:
assert "runcccc1" not in (edge["fromReportId"], edge["toReportId"])
# -------------------------------------------------------------------- graph
def test_graph_nodes_and_edges(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = graph(base, _U1)
node_ids = {node["id"] for node in payload["nodes"]}
assert node_ids == {"runaaaa1", "runbbbb1"} # DONE runs only
node = next(n for n in payload["nodes"] if n["id"] == "runaaaa1")
assert node["topic"] == "Battery Advances"
assert set(node["linkedSources"]) == {"https://example.org/x", "https://example.org/y"}
assert node["findings"] == ["Battery Advances"]
assert payload["edges"] == [
{"fromId": "runaaaa1", "toId": "runbbbb1", "sharedSourceCount": 1}
]
def test_graph_fresh_user_is_empty(tmp_path: Path) -> None:
assert graph(_tree(tmp_path), "cccc3333") == {"nodes": [], "edges": []}
# -------------------------------------------------------------------- tasks
def test_tasks_answered_iff_done(tmp_path: Path) -> None:
"""D-114: a sub-question is done IFF its run reached DONE; the
post-sweep CANCELLED run's questions stay open."""
base = _tree(tmp_path)
items = tasks(base, _U1)
by_id = {item["id"]: item for item in items}
# DONE runs -> done items with <run_id>#<idx> ids.
assert by_id["runaaaa1#0"] == {
"id": "runaaaa1#0",
"question": "battery energy density gains",
"sourceRunId": "runaaaa1",
"status": "done",
}
assert by_id["runbbbb1#0"]["status"] == "done"
# CANCELLED + FAILED + in-flight runs -> open items.
assert by_id["runcanc1#0"]["status"] == "open"
assert by_id["runcanc1#0"]["question"] == "abandoned open question"
assert by_id["runfail1#0"]["status"] == "open"
assert by_id["runinflg1#0"]["status"] == "open"
# user 2's runs absent.
assert all("runcccc1" not in item["id"] for item in items)
def test_tasks_cross_user_isolation(tmp_path: Path) -> None:
items = tasks(_tree(tmp_path), "cccc3333")
assert items == []
# -------------------------------------------------------------- suggestions
def test_suggestions_are_open_items_joined_with_titles(tmp_path: Path) -> None:
base = _tree(tmp_path)
payload = suggestions(base, _U1)
# Open items from the CANCELLED + FAILED + in-flight runs, most
# recent run first, attributed to their source run titles; nothing
# synthetic, nothing from DONE runs.
assert payload == [
{
"id": "sug-runinflg1#0",
"question": "still running question",
"sourceRunId": "runinflg1",
"sourceReportTitle": "Deep Report — still running",
},
{
"id": "sug-runfail1#0",
"question": "failed run question",
"sourceRunId": "runfail1",
"sourceReportTitle": "Rapid Answer — it broke",
},
{
"id": "sug-runcanc1#0",
"question": "abandoned open question",
"sourceRunId": "runcanc1",
"sourceReportTitle": "Deep Report — abandoned mid-flight",
},
]
def test_suggestions_fresh_user_empty_never_synthetic(tmp_path: Path) -> None:
base = _tree(tmp_path)
assert suggestions(base, "cccc3333") == []
def test_suggestions_exclude_done_runs(tmp_path: Path) -> None:
base = _tree(tmp_path)
questions = [s["question"] for s in suggestions(base, _U1)]
assert "battery energy density gains" not in questions # DONE run
# ------------------------------------------------- defensive scanning extras
def test_list_runs_skips_corrupt_state_json(tmp_path: Path) -> None:
base = tmp_path
run_dir = base / "runs" / _U1 / "corrupt01"
run_dir.mkdir(parents=True)
(run_dir / "state.json").write_text("{not json", encoding="utf-8")
assert list_runs(base, _U1) == []
+374 -1
View File
@@ -299,7 +299,7 @@
},
"put": {
"summary": "Put Settings",
"description": "Persist a partial settings update (REQ-72); returns the\nupdated settings.",
"description": "Persist a partial settings update (REQ-72); returns the\nupdated settings. An explicit null field is treated as absent\n(partial-merge semantics \u2014 the store refuses null-valued\nfields, so a null can never poison the on-disk payload).",
"operationId": "put_settings_api_config_settings_put",
"requestBody": {
"content": {
@@ -414,6 +414,27 @@
}
},
"/api/runs": {
"get": {
"summary": "List Runs Route",
"description": "All of this owner's runs from disk state files, most recent\nfirst \u2014 the registry-independent run list (post-sweep\nCANCELLED runs surface cancelled, NOT open).",
"operationId": "list_runs_route_api_runs_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/RunSummary"
},
"type": "array",
"title": "Response List Runs Route Api Runs Get"
}
}
}
}
}
},
"post": {
"summary": "Create Run",
"operationId": "create_run_api_runs_post",
@@ -703,6 +724,7 @@
"/api/runs/{run_id}/report": {
"get": {
"summary": "Run Report",
"description": "Disk-read, registry-independent (P-8 full closure, T-3.3):\nthe run directory under THIS owner is the existence check \u2014 no\nRunRegistry involvement, correct after a restart with zero\nin-memory actors. Pre-artifact DONE runs (and any run whose\nreport.json was not yet written) fall back to markdown-only\nwith null report/evidence \u2014 never a 500 (D-106: old artifacts\ndisposable).",
"operationId": "run_report_api_runs__run_id__report_get",
"parameters": [
{
@@ -744,6 +766,7 @@
"/api/runs/{run_id}/ledger": {
"get": {
"summary": "Run Ledger",
"description": "Disk-read, registry-independent (T-3.3): same owner-dir\nexistence check as the report route.",
"operationId": "run_ledger_api_runs__run_id__ledger_get",
"parameters": [
{
@@ -781,6 +804,286 @@
}
}
}
},
"/api/library": {
"get": {
"summary": "Library Route",
"operationId": "library_route_api_library_get",
"parameters": [
{
"name": "sort",
"in": "query",
"required": false,
"schema": {
"type": "string",
"default": "recent",
"title": "Sort"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"title": "Response Library Route Api Library Get"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/library/connections": {
"get": {
"summary": "Connections Route",
"operationId": "connections_route_api_library_connections_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": true,
"type": "object"
},
"type": "array",
"title": "Response Connections Route Api Library Connections Get"
}
}
}
}
}
}
},
"/api/graph": {
"get": {
"summary": "Graph Route",
"operationId": "graph_route_api_graph_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"type": "object",
"title": "Response Graph Route Api Graph Get"
}
}
}
}
}
}
},
"/api/tasks": {
"get": {
"summary": "Tasks Route",
"operationId": "tasks_route_api_tasks_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array",
"title": "Response Tasks Route Api Tasks Get"
}
}
}
}
}
}
},
"/api/suggestions": {
"get": {
"summary": "Suggestions Route",
"operationId": "suggestions_route_api_suggestions_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"type": "array",
"title": "Response Suggestions Route Api Suggestions Get"
}
}
}
}
}
}
},
"/api/refine/questions": {
"post": {
"summary": "Refine Questions",
"operationId": "refine_questions_api_refine_questions_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefineDraftBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": true,
"type": "object",
"title": "Response Refine Questions Api Refine Questions Post"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/refine/compose": {
"post": {
"summary": "Refine Compose",
"operationId": "refine_compose_api_refine_compose_post",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RefineComposeBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Refine Compose Api Refine Compose Post"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/api/profile": {
"get": {
"summary": "Get Profile",
"operationId": "get_profile_api_profile_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Get Profile Api Profile Get"
}
}
}
}
}
},
"put": {
"summary": "Put Profile",
"description": "Update display name and/or email (no deletion, REQ-81).\nThe email stays unique (casefolded) across other users.",
"operationId": "put_profile_api_profile_put",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ProfileBody"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"additionalProperties": {
"type": "string"
},
"type": "object",
"title": "Response Put Profile Api Profile Put"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
}
},
"components": {
@@ -1064,6 +1367,76 @@
"title": "ModelKeyBody",
"description": "PUT /api/config/settings/model-key body \u2014 the raw key, accepted,\nnever echoed (REQ-73)."
},
"ProfileBody": {
"properties": {
"display_name": {
"anyOf": [
{
"type": "string",
"maxLength": 120,
"minLength": 1
},
{
"type": "null"
}
],
"title": "Display Name"
},
"email": {
"anyOf": [
{
"type": "string",
"maxLength": 254,
"pattern": "^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$"
},
{
"type": "null"
}
],
"title": "Email"
}
},
"type": "object",
"title": "ProfileBody"
},
"RefineComposeBody": {
"properties": {
"draft": {
"type": "string",
"maxLength": 2000,
"minLength": 1,
"title": "Draft"
},
"answers": {
"items": {
"type": "string"
},
"type": "array",
"maxItems": 10,
"title": "Answers"
}
},
"type": "object",
"required": [
"draft"
],
"title": "RefineComposeBody"
},
"RefineDraftBody": {
"properties": {
"draft": {
"type": "string",
"maxLength": 2000,
"minLength": 1,
"title": "Draft"
}
},
"type": "object",
"required": [
"draft"
],
"title": "RefineDraftBody"
},
"RegisterBody": {
"properties": {
"email": {
+28 -3
View File
@@ -19,6 +19,7 @@ import Link from "next/link";
import { useEffect, useState } from "react";
import {
api,
liveMode,
type InboxItem,
type ProjectGraph,
type ReportConnection,
@@ -424,10 +425,19 @@ function ProjectGraphPanel({ graph }: { graph: ProjectGraph }) {
/* Task inbox panel (REQ-60) */
/* ------------------------------------------------------------------ */
/**
* G-109 (task-inbox honesty): in LIVE mode the status is DERIVED
* server-side (answered-iff the source run reached DONE — D-114), so
* the checkbox is display-honest — disabled and annotated, never a
* free toggle that silently reverts on the next fetch. Persisted
* per-question check state is v0.4+ (REQ-45 backlog). MOCK mode keeps
* the free toggle byte-for-byte (the dev fixture owns its state).
*/
function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
const [items, setItems] = useState(initialItems);
const toggle = (id: string) => {
if (liveMode) return; // display-honest: live status is derived, not local
setItems((current) =>
current.map((item) =>
item.id === id
@@ -452,9 +462,16 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
<p className="mt-1 text-caption text-ink-soft">
Open questions worth following up · {openCount} open
</p>
<p className="mt-1 text-caption text-ink-faint">
Toggles are local-only — the inbox persists in v0.2.
</p>
{liveMode ? (
<p className="mt-1 text-caption text-ink-faint" data-testid="inbox-derived-note">
A question is answered when its research run completes — status
follows the run.
</p>
) : (
<p className="mt-1 text-caption text-ink-faint">
Toggles are local-only — the inbox persists in v0.2.
</p>
)}
<ul className="mt-4 space-y-2">
{items.map((item) => (
@@ -463,7 +480,15 @@ function TaskInboxPanel({ initialItems }: { initialItems: InboxItem[] }) {
type="checkbox"
checked={item.status === "done"}
onChange={() => toggle(item.id)}
// G-109: derived status in live mode — disabled, never a
// silent revert; mock keeps the free toggle.
disabled={liveMode}
aria-label={item.question}
title={
liveMode
? "Follows its research run — answered when the run completes"
: undefined
}
className="mt-1 h-4 w-4 accent-[var(--color-accent)]"
/>
<span
@@ -0,0 +1,374 @@
/**
* Live-mode component tests (T-3.9, G-109; REQ-68, REQ-80).
*
* The previously-delegated surfaces driving REAL components in live
* mode — no boundary mocks, the ApiClient surface only:
* - G-109: the task-inbox checkbox is DISPLAY-HONEST in live mode —
* derived status (open-iff run not DONE), disabled + annotated;
* a click never flips it; the mock keeps the free toggle (pinned
* by the unchanged library-view suite).
* - Refine honesty: a 503 from the real planner endpoint surfaces
* the error state — never a hang, never synthetic questions.
*
* House pattern (user-menu-session suite): the module's `api` +
* `liveMode` exports are swapped per case through module
* interception; the components themselves are UNTOUCHED.
*/
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AskView } from "@/components/ask-view";
import { LibraryView } from "@/components/library-view";
import type {
InboxItem,
ProjectGraph,
ReportConnection,
ReportLibrary,
} from "@/lib/api/client";
const pushMock = vi.hoisted(() => vi.fn());
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock }),
}));
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
liveMode: false,
}));
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: new Proxy(
{},
{
get: (_target, prop) => {
const handler = state.api[prop as string];
return typeof handler === "function"
? (handler as (...args: unknown[]) => unknown).bind(state.api)
: handler;
},
},
),
get liveMode() {
return state.liveMode;
},
};
});
/* ------------------------------------------------------------------ */
/* Live-mode boundary stub — payloads verbatim from the read-models */
/* ------------------------------------------------------------------ */
const LIVE_LIBRARY: ReportLibrary = {
folders: [
{
id: "deep-report-battery",
name: "Deep Report: Battery",
description: "Auto-filed from deep report battery research.",
},
],
reports: [
{
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
},
],
};
const LIVE_CONNECTIONS: ReportConnection[] = [
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
];
const LIVE_GRAPH: ProjectGraph = {
nodes: [
{
id: "runaaaa1",
topic: "Battery Advances",
linkedSources: ["https://example.org/x"],
findings: ["Battery Advances"],
},
],
edges: [],
};
const LIVE_INBOX: InboxItem[] = [
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
];
/** A live-shaped ApiClient surface for the library panels. */
function liveLibraryApi(): Record<string, unknown> {
return {
getReportLibrary: vi.fn().mockResolvedValue(LIVE_LIBRARY),
getReportConnections: vi.fn().mockResolvedValue(LIVE_CONNECTIONS),
getProjectGraph: vi.fn().mockResolvedValue(LIVE_GRAPH),
getTaskInbox: vi.fn().mockResolvedValue(LIVE_INBOX),
};
}
beforeEach(() => {
pushMock.mockClear();
});
afterEach(cleanup);
/* ------------------------------------------------------------------ */
/* G-109 — the task-inbox checkbox is display-honest in live mode */
/* ------------------------------------------------------------------ */
describe("LibraryView task inbox — live-mode honesty (G-109)", () => {
it("renders every live surface: folders, connections, graph, tasks", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
render(<LibraryView />);
// Library feeds from the LIVE read-model payload (T-3.9).
const card = await screen.findByRole("link", { name: /Battery Advances/i });
expect(card).toHaveAttribute("href", "/run/runaaaa1/report");
expect(screen.getByRole("button", { name: /Deep Report: Battery/i })).toBeInTheDocument();
expect(screen.getByText(/1 citation/i)).toBeInTheDocument();
expect(
screen.getByText(/Shared sources — 2 shared sources/i),
).toBeInTheDocument();
// The graph panel renders the run as a node (topic = report title).
const graph = await screen.findByRole("region", { name: "Project graph" });
expect(graph.textContent).toContain("Battery Advances");
});
it("live checkboxes are DISABLED and the derived-status note shows", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
render(<LibraryView />);
await screen.findByRole("region", { name: "Task inbox" });
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
const done = await screen.findByRole("checkbox", {
name: "An answered question",
});
// Derived status rendered — open stays open, done stays done.
expect(open).not.toBeChecked();
expect(done).toBeChecked();
// G-109: display-honest — both disabled.
expect(open).toBeDisabled();
expect(done).toBeDisabled();
// Annotated, never silent.
expect(
screen.getByTestId("inbox-derived-note"),
).toHaveTextContent(/answered when its research run completes/i);
// The open count reflects the server-derived statuses.
expect(
screen.getByText(/open questions worth following up · 1 open/i),
).toBeInTheDocument();
});
it("a click on a live checkbox never flips the derived status", async () => {
state.api = liveLibraryApi();
state.liveMode = true;
const user = userEvent.setup();
render(<LibraryView />);
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
await user.click(open);
// Still unchecked — no silent revert because nothing ever changed.
expect(open).not.toBeChecked();
expect(
screen.getByText(/open questions worth following up · 1 open/i),
).toBeInTheDocument();
});
it("mock mode keeps the free toggle byte-for-byte (G-109 dev fixture)", async () => {
state.api = liveLibraryApi();
state.liveMode = false;
const user = userEvent.setup();
render(<LibraryView />);
const open = await screen.findByRole("checkbox", {
name: "What is the research question?",
});
expect(open).toBeEnabled();
expect(
screen.queryByTestId("inbox-derived-note"),
).toBeNull();
await user.click(open);
expect(open).toBeChecked();
expect(
screen.getByText(/open questions worth following up · 0 open/i),
).toBeInTheDocument();
});
});
/* ------------------------------------------------------------------ */
/* Refine — the real exchange with an honest 503 (T-3.6, REQ-80) */
/* ------------------------------------------------------------------ */
describe("RefineDrawer — live-mode 503 honesty", () => {
it("surfaces the honest unavailable state — never a hang, never synthetic", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi
.fn()
.mockRejectedValue(new Error("live: refine is unavailable — 503")),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
expect(
await screen.findByRole("region", { name: /Refine your question/i }),
).toBeInTheDocument();
// The honest error state — the drawer never invents questions.
expect(
await screen.findByRole("alert"),
).toHaveTextContent(/Refine is unavailable right now/i);
expect(
screen.queryByLabelText(/matters most to you/i),
).toBeNull();
// Keep original closes the drawer with the draft untouched.
await user.click(screen.getByRole("button", { name: "Keep original" }));
expect(
screen.queryByRole("region", { name: /Refine your question/i }),
).toBeNull();
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"What are the latest advances in solid-state batteries",
);
});
it("drives the REAL live exchange: model questions → model composition", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi.fn().mockResolvedValue([
{
id: "q-1",
prompt: "Which application domain matters most?",
placeholder: "one short answer",
optional: false,
},
{
id: "q-2",
prompt: "What decision will this research inform?",
placeholder: "one short answer",
optional: true,
},
]),
composeRefinedQuestion: vi
.fn()
.mockResolvedValue({
question: "Solid-state batteries — automotive applications",
context: "Feeds a supplier choice.",
}),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
await user.type(
screen.getByLabelText(/what do you want to know\?/i),
"What are the latest advances in solid-state batteries",
);
await user.click(screen.getByRole("button", { name: /Refine question/i }));
const q1 = await screen.findByLabelText(/Which application domain matters most\?/i);
await user.type(q1, "automotive applications");
await user.click(screen.getByRole("button", { name: "Next" }));
const q2 = await screen.findByLabelText(/What decision will this research inform\?/i);
await user.type(q2, "choosing a supplier");
await user.click(screen.getByRole("button", { name: "Compose question" }));
// The model-composed refinement arrives and fills the form.
expect(
await screen.findByText("Solid-state batteries — automotive applications"),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Use this" }));
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"Solid-state batteries — automotive applications",
);
expect(screen.getByLabelText(/anything else it should know\?/i)).toHaveValue(
"Feeds a supplier choice.",
);
});
it("live suggestions render with source attribution and never auto-start", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([
{
id: "sug-runcccc1#0",
question: "What is the research question?",
sourceRunId: "runcccc1",
sourceReportTitle: "Deep Report — an in-flight run",
},
]),
getRefinementQuestions: vi.fn(),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
const user = userEvent.setup();
render(<AskView />);
const card = await screen.findByRole("button", {
name: /What is the research question\?/i,
});
expect(
screen.getByText(/From your research — Deep Report — an in-flight run/i),
).toBeInTheDocument();
await user.click(card);
// Click-fills the box — never auto-starts.
expect(screen.getByLabelText(/what do you want to know\?/i)).toHaveValue(
"What is the research question?",
);
expect(pushMock).not.toHaveBeenCalled();
});
it("a fresh user sees the honest empty suggestions state in live mode", async () => {
state.api = {
getSuggestedQueries: vi.fn().mockResolvedValue([]),
getRefinementQuestions: vi.fn(),
composeRefinedQuestion: vi.fn(),
};
state.liveMode = true;
render(<AskView />);
await waitFor(() => {
expect(
screen.getByText(/No open questions yet — they collect here as you run research/i),
).toBeInTheDocument();
});
});
});
@@ -0,0 +1,439 @@
/**
* Reading Room live parity + honest exports (T-3.10, REQ-82, REQ-59,
* REQ-34 — P-12 closure pins).
*
* The REAL ReadingRoom component rendering a REAL live payload —
* the enriched GET /api/runs/{id}/report envelope (report.json +
* evidence.json + markdown + ledger) stubbed at fetch level:
* - structured sections render with [^pN] markers (zero
* report-body.ts changes — C3-10);
* - hover/focus on a marker reveals the EXACT stored quoted passage
* (parity with the mock Reading Room, REQ-82);
* - the ledger panel lists real entries with real quotes;
* - exports carry REAL data: markdown from the served markdown,
* JSON with real claims_supported/health/tier/quotes — the
* fabricated claims_supported:1 and empty quotes are DEAD, and a
* test pins their absence (P-12).
*
* House pattern (user-menu-session suite): the module's `api` export
* is swapped per case through a hoisted module interception backed by
* a GeneratedApiClient over a stubbed fetch — the components
* themselves are UNTOUCHED, and every call still speaks only the
* ApiClient surface.
*/
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ReadingRoom } from "@/components/reading-room";
import { GeneratedApiClient } from "@/lib/api/live-client";
const state = vi.hoisted(() => ({
api: {} as Record<string, unknown>,
}));
vi.mock("@/lib/api/client", async (importOriginal) => {
const actual = await importOriginal<
Promise<typeof import("@/lib/api/client")>
>();
return {
...actual,
api: new Proxy(
{},
{
get: (_target, prop) => {
const handler = state.api[prop as string];
return typeof handler === "function"
? (handler as (...args: unknown[]) => unknown).bind(state.api)
: handler;
},
},
),
};
});
/* ------------------------------------------------------------------ */
/* Fixture live payload — the DONE-run envelope, verbatim shapes */
/* ------------------------------------------------------------------ */
const RUN_ID = "runaaaa1";
const FIXTURE_ENVELOPE = {
run_id: RUN_ID,
markdown:
"# Battery Advances\n\nThe verified core finding [p-1]. A second corroborated observation [p-2].\n",
report: {
v: 1,
run_id: RUN_ID,
owner_id: "abcd2345",
title: "Battery Advances",
question: "What about battery research?",
methodology_slug: "deep-report",
generated_at: "2026-09-20T10:05:00Z",
sections: [
{
heading: "Research Memo",
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
},
],
citations: [
{
passage_id: "p1",
url: "https://example.org/x",
title: "Verification passes",
quote: "The verified core finding.",
health: "live",
authority_tier: "official",
},
{
passage_id: "p2",
url: "https://example.com/y",
title: "Quote anchoring and auditability",
quote: "Anchoring makes auditability real.",
health: "stale",
authority_tier: "general",
},
],
},
evidence: {
v: 1,
run_id: RUN_ID,
passages: [
{
passage_id: "p1",
url: "https://example.org/x",
title: "Verification passes",
quote: "The verified core finding.",
text: "The verified core finding in full, stored at DONE.",
},
{
passage_id: "p2",
url: "https://example.com/y",
title: "Quote anchoring and auditability",
quote: "Anchoring makes auditability real.",
text: "Anchoring is the mechanism that makes auditability real.",
},
],
},
};
const FIXTURE_LEDGER = {
run_id: RUN_ID,
generated_at: "2026-09-20T10:05:00Z",
entries: [
{
url: "https://example.org/x",
visited_at: "2026-09-20T10:04:00Z",
passages: ["p-1"],
claims_supported: 2,
health: "live",
authority_tier: "official",
},
{
url: "https://example.com/y",
visited_at: "2026-09-20T10:04:30Z",
passages: ["p-2"],
claims_supported: 1,
health: "stale",
authority_tier: "general",
},
],
checklist: {
run_id: RUN_ID,
citations_checked: 2,
live: 1,
stale: 1,
dead: 0,
},
};
/** A fetch stub serving the DONE-run report envelope + ledger. */
function liveStackFetch(): typeof globalThis.fetch {
const routes: Record<string, unknown> = {
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/report`]: FIXTURE_ENVELOPE,
[`http://127.0.0.1:8000/api/runs/${RUN_ID}/ledger`]: FIXTURE_LEDGER,
};
return (async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
for (const [prefix, body] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
/** The live client over the stubbed stack — the boundary per case. */
function liveApi(): Record<string, unknown> {
const client = new GeneratedApiClient(liveStackFetch());
return client as unknown as Record<string, unknown>;
}
beforeEach(() => {
state.api = liveApi();
});
afterEach(cleanup);
/* ------------------------------------------------------------------ */
/* Component parity — the REAL ReadingRoom over the live payload */
/* ------------------------------------------------------------------ */
describe("ReadingRoom live parity (T-3.10, REQ-82)", () => {
it("renders structured sections + [^pN] markers + the real title", async () => {
render(<ReadingRoom id={RUN_ID} />);
// The REAL report.json title — not a placeholder.
expect(await screen.findByText("Battery Advances")).toBeInTheDocument();
// Structured section heading from report.json.
expect(screen.getByText("Research Memo")).toBeInTheDocument();
// Section body text renders around the parsed [^pN] markers —
// both text segments of the memo section are present.
const memo = screen.getByText("Research Memo").closest("section");
expect(memo?.textContent).toContain("The verified core finding ");
expect(memo?.textContent).toContain("A second corroborated observation");
// Markers render as citation buttons (marker index from pN).
expect(
screen.getAllByRole("button", { name: /Citation 1:/i }).length,
).toBeGreaterThan(0);
expect(
screen.getAllByRole("button", { name: /Citation 2:/i }).length,
).toBeGreaterThan(0);
});
it("hover/focus reveals the EXACT stored quoted passage (REQ-82 parity)", async () => {
render(<ReadingRoom id={RUN_ID} />);
const markers = await screen.findAllByRole("button", { name: /Citation 1:/i });
markers[0]?.focus();
const tooltip = await screen.findByRole("tooltip");
// The stored passage quote — exactly what the engine anchored.
expect(tooltip.textContent).toContain("The verified core finding.");
expect(tooltip.textContent).toContain("Verification passes");
expect(tooltip.textContent).toContain("example.org");
// Tier map: official → 1.
expect(tooltip.textContent).toContain("tier 1");
});
it("the ledger panel lists the REAL entries with quotes + health", async () => {
render(<ReadingRoom id={RUN_ID} />);
expect(await screen.findByText("Citation ledger")).toBeInTheDocument();
expect(
screen.getByText("2 sources · every claim traceable to a visited passage"),
).toBeInTheDocument();
// Real entry titles + real quotes.
expect(screen.getByText("Verification passes")).toBeInTheDocument();
expect(
screen.getByText("Quote anchoring and auditability"),
).toBeInTheDocument();
expect(screen.getByText(/“The verified core finding\.”/)).toBeInTheDocument();
// Health badges from the real ledger health values.
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
expect(screen.getByText(/stale/i)).toBeInTheDocument();
});
it("markdown-only fallback renders honestly for a pre-artifact DONE run", async () => {
// Swap in a pre-artifact envelope: markdown only, null artifacts —
// with a ledger still served (the run reached DONE and the ledger
// predates report.json; the route never 500s — D-106).
const preArtifact = new GeneratedApiClient(
(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/report")) {
return new Response(
JSON.stringify({
run_id: "runbbbb1",
markdown: "# Old Report\n\nRaw [p-1] markers only.",
report: null,
evidence: null,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (url.includes("/ledger")) {
return new Response(
JSON.stringify({
run_id: "runbbbb1",
generated_at: "2026-09-19T10:05:00Z",
entries: [],
checklist: { run_id: "runbbbb1", citations_checked: 0 },
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(JSON.stringify({ detail: "not found" }), {
status: 404,
});
}) as typeof globalThis.fetch,
);
state.api = preArtifact as unknown as Record<string, unknown>;
render(<ReadingRoom id="runbbbb1" />);
// The served markdown, verbatim — never a 500, never fabricated
// sections or citations.
expect(await screen.findByText("Report")).toBeInTheDocument();
expect(screen.getByText(/Raw \[p-1\] markers only\./i)).toBeInTheDocument();
// No citation markers as buttons — there are no citations.
expect(screen.queryByRole("button", { name: /Citation/i })).toBeNull();
});
});
/* ------------------------------------------------------------------ */
/* Honest exports — P-12 closure pins (boundary level) */
/* ------------------------------------------------------------------ */
describe("exportReport — real data, P-12 fabrication dead (T-3.10, REQ-34)", () => {
it("markdown export carries the SERVED markdown + a real-quote citation block", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const markdown = await client.exportReport(RUN_ID, "markdown");
expect(markdown.filename).toBe(`researchx-${RUN_ID}.md`);
// The served report.md verbatim — raw [p-N] markers intact (C3-10).
expect(markdown.content).toContain("# Battery Advances");
expect(markdown.content).toContain("The verified core finding [p-1].");
// Citations block: real URLs as links + real health/tier.
expect(markdown.content).toContain("](https://example.org/x)");
expect(markdown.content).toContain("✓ live");
expect(markdown.content).toContain("△ stale");
// The REAL stored quote appears in the citation block.
expect(markdown.content).toContain("The verified core finding.");
});
it("JSON export carries REAL claims_supported/health/tier/quotes per entry", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const json = await client.exportReport(RUN_ID, "json");
expect(json.filename).toBe(`researchx-${RUN_ID}.json`);
const parsed = JSON.parse(json.content) as {
run_id: string;
generated_at: string;
entries: Array<{
url: string;
visited_at: string;
passages: string[];
claims_supported: number;
health: string;
authority_tier: string;
quote: string;
}>;
checklist: Record<string, unknown>;
};
expect(parsed.run_id).toBe(RUN_ID);
expect(parsed.entries).toHaveLength(2);
// Entry 0: the REAL values from ledger.json — claims_supported 2
// (NOT the fabricated 1), live health, official tier.
expect(parsed.entries[0]?.url).toBe("https://example.org/x");
expect(parsed.entries[0]?.claims_supported).toBe(2);
expect(parsed.entries[0]?.health).toBe("live");
expect(parsed.entries[0]?.authority_tier).toBe("official");
// Entry 1 keeps its own real values — the entries are not clones.
expect(parsed.entries[1]?.claims_supported).toBe(1);
expect(parsed.entries[1]?.health).toBe("stale");
// The REAL stored quotes — never empty strings.
expect(parsed.entries[0]?.quote).toBe("The verified core finding.");
expect(parsed.entries[1]?.quote).toBe("Anchoring makes auditability real.");
// The real checklist from the ledger artifact.
expect(parsed.checklist).toEqual(FIXTURE_LEDGER.checklist);
});
it("P-12 pin: the fabricated claims_supported:1 and empty quotes are ABSENT", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const json = await client.exportReport(RUN_ID, "json");
// Every entry carries its OWN claims count — an all-1s dump would
// mean the fabrication is back (entry 0's real count is 2).
const claims = JSON.parse(json.content) as {
entries: Array<{ claims_supported: number; quote: string }>;
};
expect(claims.entries.map((e) => e.claims_supported)).toEqual([2, 1]);
// No empty quotes anywhere.
for (const entry of claims.entries) {
expect(entry.quote.length).toBeGreaterThan(0);
}
// And the export never emits the mock-era placeholder title either.
expect(json.content).not.toContain('"Research Report"');
});
});
/* ------------------------------------------------------------------ */
/* getReport/getLedger mappings — the boundary contract */
/* ------------------------------------------------------------------ */
describe("getReport/getLedger live mappings (T-3.10)", () => {
it("getReport maps report.json → Report with real quotes + the tier map", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const report = await client.getReport(RUN_ID);
expect(report.runId).toBe(RUN_ID);
expect(report.title).toBe("Battery Advances");
expect(report.sections).toEqual([
{
heading: "Research Memo",
body: "The verified core finding [^p1]. A second corroborated observation [^p2].",
},
]);
// [^pN] markers survive byte-for-byte — zero report-body.ts changes.
expect(report.sections[0]?.body).toMatch(/\[\^p1\]/);
expect(report.citations).toHaveLength(2);
// Tier map: official→1, general→3.
expect(report.citations[0]).toEqual({
id: "c-1",
url: "https://example.org/x",
title: "Verification passes",
site: "example.org",
passageId: "p1",
quote: "The verified core finding.",
verification: "live",
authorityTier: 1,
});
expect(report.citations[1]?.authorityTier).toBe(3);
expect(report.citations[1]?.site).toBe("example.com");
expect(report.citations[1]?.verification).toBe("stale");
});
it("getLedger maps real entries with pN-normalized ids + stored quotes", async () => {
const client = new GeneratedApiClient(liveStackFetch());
const ledger = await client.getLedger(RUN_ID);
expect(ledger.runId).toBe(RUN_ID);
expect(ledger.generatedAt).toBe("2026-09-20T10:05:00Z");
expect(ledger.citations).toHaveLength(2);
// ledger.json ids are p-N; the Citation passageId is the pN form
// the [^pN] markers parse to.
expect(ledger.citations[0]?.passageId).toBe("p1");
expect(ledger.citations[0]?.quote).toBe("The verified core finding.");
expect(ledger.citations[0]?.authorityTier).toBe(1);
expect(ledger.citations[1]?.passageId).toBe("p2");
expect(ledger.citations[1]?.verification).toBe("stale");
});
it("the report + ledger share one network read per DONE run (memoized envelope)", async () => {
let reportCalls = 0;
const counting = new GeneratedApiClient(
(async (input: RequestInfo | URL) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes("/report")) {
reportCalls += 1;
return new Response(JSON.stringify(FIXTURE_ENVELOPE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
if (url.includes("/ledger")) {
return new Response(JSON.stringify(FIXTURE_LEDGER), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ detail: "not found" }), {
status: 404,
});
}) as typeof globalThis.fetch,
);
await counting.getReport(RUN_ID);
await counting.getLedger(RUN_ID);
await counting.exportReport(RUN_ID, "markdown");
// report route hit exactly ONCE: the envelope is memoized per run
// once report.json exists (a terminal run's artifacts are final).
expect(reportCalls).toBe(1);
});
});
+7 -4
View File
@@ -4,7 +4,9 @@
* Components consume ONLY this interface via the `api` singleton. In
* v0.1 the singleton is bound to MockApiClient; in v0.2 phase 3 the
* binding swaps to the OpenAPI-generated client with no component
* rewrites. This is the ONLY module allowed to import MockApiClient.
* rewrites. Since v0.3 P3 (D-112) the live client is standalone —
* this boundary module selects the binding, and it is the ONLY module
* allowed to import MockApiClient.
*/
import type {
@@ -315,8 +317,9 @@ export const liveMode: boolean =
* The app-wide singleton. v0.1: MockApiClient (fixture data + scripted
* event replay, zero API keys). v0.2 P3 (REQ-26/REQ-54): the binding
* switches to the OpenAPI-generated client in live mode — selected by
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Live mode covers
* the core research loop only (G-002); other surfaces stay mock-backed.
* NEXT_PUBLIC_RESEARCHX_API_MODE=live (mock stays the default). Since
* v0.3 P3 (D-112) the live client is STANDALONE — every surface
* live-bound, zero delegation to the mock.
*/
class MockApiClientWithLifecycle extends MockApiClient implements RunLifecycle {
async pauseRun(id: string): Promise<ResearchRun> {
@@ -333,7 +336,7 @@ function resolveBinding(): ApiClient & RunLifecycle {
process.env.RESEARCHX_API_MODE ??
"mock";
if (mode === "live") {
return new GeneratedApiClient() as unknown as ApiClient & RunLifecycle;
return new GeneratedApiClient();
}
return new MockApiClientWithLifecycle();
}
+453 -4
View File
@@ -163,7 +163,9 @@ export interface paths {
/**
* Put Settings
* @description Persist a partial settings update (REQ-72); returns the
* updated settings.
* updated settings. An explicit null field is treated as absent
* (partial-merge semantics — the store refuses null-valued
* fields, so a null can never poison the on-disk payload).
*/
put: operations["put_settings_api_config_settings_put"];
post?: never;
@@ -223,7 +225,13 @@ export interface paths {
path?: never;
cookie?: never;
};
get?: never;
/**
* List Runs Route
* @description All of this owner's runs from disk state files, most recent
* first — the registry-independent run list (post-sweep
* CANCELLED runs surface cancelled, NOT open).
*/
get: operations["list_runs_route_api_runs_get"];
put?: never;
/** Create Run */
post: operations["create_run_api_runs_post"];
@@ -342,7 +350,16 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Run Report */
/**
* Run Report
* @description Disk-read, registry-independent (P-8 full closure, T-3.3):
* the run directory under THIS owner is the existence check — no
* RunRegistry involvement, correct after a restart with zero
* in-memory actors. Pre-artifact DONE runs (and any run whose
* report.json was not yet written) fall back to markdown-only
* with null report/evidence — never a 500 (D-106: old artifacts
* disposable).
*/
get: operations["run_report_api_runs__run_id__report_get"];
put?: never;
post?: never;
@@ -359,7 +376,11 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Run Ledger */
/**
* Run Ledger
* @description Disk-read, registry-independent (T-3.3): same owner-dir
* existence check as the report route.
*/
get: operations["run_ledger_api_runs__run_id__ledger_get"];
put?: never;
post?: never;
@@ -369,6 +390,147 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/library": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Library Route */
get: operations["library_route_api_library_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/library/connections": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Connections Route */
get: operations["connections_route_api_library_connections_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/graph": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Graph Route */
get: operations["graph_route_api_graph_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/tasks": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Tasks Route */
get: operations["tasks_route_api_tasks_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/suggestions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Suggestions Route */
get: operations["suggestions_route_api_suggestions_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/refine/questions": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Refine Questions */
post: operations["refine_questions_api_refine_questions_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/refine/compose": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Refine Compose */
post: operations["refine_compose_api_refine_compose_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/profile": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Profile */
get: operations["get_profile_api_profile_get"];
/**
* Put Profile
* @description Update display name and/or email (no deletion, REQ-81).
* The email stays unique (casefolded) across other users.
*/
put: operations["put_profile_api_profile_put"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@@ -494,6 +656,25 @@ export interface components {
/** Key */
key: string;
};
/** ProfileBody */
ProfileBody: {
/** Display Name */
display_name?: string | null;
/** Email */
email?: string | null;
};
/** RefineComposeBody */
RefineComposeBody: {
/** Draft */
draft: string;
/** Answers */
answers?: string[];
};
/** RefineDraftBody */
RefineDraftBody: {
/** Draft */
draft: string;
};
/** RegisterBody */
RegisterBody: {
/** Email */
@@ -960,6 +1141,26 @@ export interface operations {
};
};
};
list_runs_route_api_runs_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["RunSummary"][];
};
};
};
};
create_run_api_runs_post: {
parameters: {
query?: never;
@@ -1249,4 +1450,252 @@ export interface operations {
};
};
};
library_route_api_library_get: {
parameters: {
query?: {
sort?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
connections_route_api_library_connections_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
}[];
};
};
};
};
graph_route_api_graph_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
};
};
tasks_route_api_tasks_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
}[];
};
};
};
};
suggestions_route_api_suggestions_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
}[];
};
};
};
};
refine_questions_api_refine_questions_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RefineDraftBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: unknown;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
refine_compose_api_refine_compose_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RefineComposeBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_profile_api_profile_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
};
};
put_profile_api_profile_put: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ProfileBody"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
[key: string]: string;
};
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}
+105 -29
View File
@@ -9,15 +9,35 @@ import { describe, expect, it } from "vitest";
import { GeneratedApiClient } from "@/lib/api/live-client";
import type { BackendRun } from "@/lib/api/test-helpers";
type RouteHandler = (url: string, init?: RequestInit) => { status: number; body: unknown };
type RouteHandler = (
url: string,
init?: RequestInit,
body?: string,
) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
// openapi-fetch serializes the body onto the Request — surface it
// (plus credentials/headers) back to the route handler so tests
// can assert the CSRF header and cookie discipline per call.
let body: string | undefined;
let sentInit: RequestInit | undefined = init;
if (request) {
const cloned = request.clone();
body = cloned.body ? await cloned.text() : undefined;
sentInit = {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
};
}
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body } = handler(url, init);
return new Response(JSON.stringify(body), {
const { status, body: responseBody } = handler(url, sentInit, body);
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
@@ -27,6 +47,26 @@ function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fet
}) as typeof globalThis.fetch;
}
/** Headers actually sent on a call (CSRF + cookie assertions). */
function headersOf(init?: RequestInit): Record<string, string> {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
}
const backendRun: BackendRun = {
id: "abcd2345",
status: "awaiting_approval",
@@ -54,7 +94,20 @@ describe("GeneratedApiClient (live binding)", () => {
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest path to a confident cited answer.",
output_shape: "memo",
dials: { depth: 1, breadth: 1 },
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
],
}),
@@ -65,18 +118,41 @@ describe("GeneratedApiClient (live binding)", () => {
expect(cards[0]?.slug).toBe("rapid-answer");
expect(cards[0]?.etaMinutes).toBe(2);
expect(cards[0]?.costEstimate).toContain("estimate");
expect(cards[0]?.defaultDials).toEqual({ depth: 1, breadth: 1 });
// Snake_case dials map into the full 12-key frontend DialValues
// shape (T-3.8): the Advanced drawer reads every key.
expect(cards[0]?.defaultDials).toEqual({
sourceUniverse: "web",
searchRouting: "single",
queryStrategy: "decomposed",
breadth: 1,
depth: 1,
qualityGates: "standard",
citationRigor: "per-claim",
verificationPasses: 1,
synthesisStyle: "memo",
outputStructure: "brief",
memoryScope: "none",
checkpoints: "plan-only",
});
});
it("creates runs through the backend contract", async () => {
const routes = {
"http://127.0.0.1:8000/api/runs": (url: string, init?: RequestInit) => {
const method =
init?.method ?? (url.endsWith("/api/runs") ? "POST" : undefined);
return {
status: 201,
body: method === "POST" ? backendRun : { detail: "wrong method" },
};
"http://127.0.0.1:8000/api/runs": (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(init?.method).toBe("POST");
// T-3.8 — the P1-verify flag stays closed: EVERY core-loop
// mutation rides credentials + the CSRF header (C3-05).
expect(init?.credentials).toBe("include");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(JSON.parse(body ?? "null")).toMatchObject({
question: "How do engines run?",
methodology: "rapid-answer",
});
return { status: 201, body: backendRun };
},
};
const client = new GeneratedApiClient(stubRoutes(routes));
@@ -91,29 +167,29 @@ describe("GeneratedApiClient (live binding)", () => {
});
it("approves, cancels, pauses, resumes via lifecycle routes", async () => {
// The discipline holds on EVERY lifecycle mutation, not just the
// create — the wrapper is the single injection point (T-3.8).
const seen: Array<Record<string, unknown>> = [];
const capture =
(status: string) =>
(_url: string, init?: RequestInit): { status: number; body: unknown } => {
expect(init?.credentials).toBe("include");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
seen.push({ method: init?.method, status });
return { status: 200, body: { ...backendRun, status } };
};
const routes = {
"http://127.0.0.1:8000/api/runs/abcd2345/approve": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": () => ({
status: 200,
body: { ...backendRun, status: "cancelled" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/pause": () => ({
status: 200,
body: { ...backendRun, status: "searching" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/resume": () => ({
status: 200,
body: { ...backendRun, status: "verifying" },
}),
"http://127.0.0.1:8000/api/runs/abcd2345/approve": capture("searching"),
"http://127.0.0.1:8000/api/runs/abcd2345/cancel": capture("cancelled"),
"http://127.0.0.1:8000/api/runs/abcd2345/pause": capture("searching"),
"http://127.0.0.1:8000/api/runs/abcd2345/resume": capture("verifying"),
};
const client = new GeneratedApiClient(stubRoutes(routes));
expect((await client.approvePlan("abcd2345")).status).toBe("searching");
expect((await client.cancelRun("abcd2345")).status).toBe("cancelled");
expect((await client.pauseRun("abcd2345")).status).toBe("searching");
expect((await client.resumeRun("abcd2345")).status).toBe("verifying");
expect(seen.map((s) => s.method)).toEqual(["POST", "POST", "POST", "POST"]);
});
it("maps the ledger to frontend citations with health + tier", async () => {
File diff suppressed because it is too large Load Diff
+564
View File
@@ -0,0 +1,564 @@
/**
* Live-surface boundary tests (T-3.9, REQ-76..81 — the surface swaps).
*
* Every previously-delegated surface, contract-tested against a
* stubbed global fetch (no network, no keys): real routes, cookie
* discipline on every call, CSRF header on every mutation (C3-05),
* payloads mapped through the SAME frontend types the components
* consume (zero rewrites proven — REQ-54).
*/
import { describe, expect, it } from "vitest";
import { GeneratedApiClient } from "@/lib/api/live-client";
import { MockApiClient } from "@/lib/api/mock/mock-client";
type RouteHandler = (
url: string,
init?: RequestInit,
body?: string,
) => { status: number; body: unknown };
function stubRoutes(routes: Record<string, RouteHandler>): typeof globalThis.fetch {
return (async (input: RequestInfo | URL, init?: RequestInit) => {
const request = input instanceof Request ? input : undefined;
const url = request ? request.url : String(input);
// openapi-fetch serializes the body onto the Request — surface it
// (plus credentials/headers) back to the route handler.
let body: string | undefined;
let sentInit: RequestInit | undefined = init;
if (request) {
const cloned = request.clone();
body = cloned.body ? await cloned.text() : undefined;
sentInit = {
...init,
method: request.method,
credentials: request.credentials,
headers: request.headers,
};
}
for (const [prefix, handler] of Object.entries(routes)) {
if (url.startsWith(prefix)) {
const { status, body: responseBody } = handler(url, sentInit, body);
return new Response(JSON.stringify(responseBody), {
status,
headers: { "Content-Type": "application/json" },
});
}
}
return new Response(JSON.stringify({ detail: "not found" }), { status: 404 });
}) as typeof globalThis.fetch;
}
/** Headers actually sent on a call (CSRF + cookie assertions). */
function headersOf(init?: RequestInit): Record<string, string> {
const raw = init?.headers;
const record: Record<string, string> = {};
if (
typeof raw === "object" &&
raw !== null &&
typeof (raw as Headers).forEach === "function"
) {
(raw as Headers).forEach((v, k) => {
record[k] = v;
});
} else if (Array.isArray(raw)) {
for (const [k, v] of raw) record[String(k)] = String(v);
} else if (raw) {
Object.assign(record, raw);
}
return record;
}
const BASE = "http://127.0.0.1:8000";
/* ------------------------------------------------------------------ */
/* Read-model payloads (verbatim shapes from the live app) */
/* ------------------------------------------------------------------ */
const LIBRARY_PAYLOAD = {
folders: [
{
id: "deep-report-battery",
name: "Deep Report: Battery",
description: "Auto-filed from deep report battery research.",
},
],
reports: [
{
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
},
],
};
const CONNECTIONS_PAYLOAD = [
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runcccc1",
kind: "related-topic",
label: "Adjacent topic",
},
];
const GRAPH_PAYLOAD = {
nodes: [
{
id: "runaaaa1",
topic: "Battery Advances",
linkedSources: ["https://example.org/x"],
findings: ["Battery Advances"],
},
],
edges: [{ fromId: "runaaaa1", toId: "runbbbb1", sharedSourceCount: 2 }],
};
const TASKS_PAYLOAD = [
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
];
const SUGGESTIONS_PAYLOAD = [
{
id: "sug-runcccc1#0",
question: "What is the research question?",
sourceRunId: "runcccc1",
sourceReportTitle: "Deep Report — an in-flight run",
},
];
const PROFILE_PAYLOAD = {
user_id: "abcd2345",
display_name: "Ada Owner",
email: "owner@example.org",
};
const CONFIG_PAYLOAD = {
resolved_tier: "user",
key_store: "enabled",
key_status: {
configured: true,
masked: "…7f2a",
disabled: false,
reason: null,
},
model_adapter: "OllamaModelAdapter",
search_adapter: "TavilySearchAdapter",
storage_dir: "/var/lib/researchx/runs",
probe: null,
cost_counters: { runs_created: 4, runs_completed: 3 },
};
/** One stub serving every read-model surface at once. */
function surfaceRoutes(): Record<string, RouteHandler> {
return {
[`${BASE}/api/library/connections`]: () => ({
status: 200,
body: CONNECTIONS_PAYLOAD,
}),
[`${BASE}/api/library`]: (_url: string, init?: RequestInit) => {
expect(init?.credentials).toBe("include");
return { status: 200, body: LIBRARY_PAYLOAD };
},
[`${BASE}/api/graph`]: () => ({ status: 200, body: GRAPH_PAYLOAD }),
[`${BASE}/api/tasks`]: () => ({ status: 200, body: TASKS_PAYLOAD }),
[`${BASE}/api/suggestions`]: () => ({
status: 200,
body: SUGGESTIONS_PAYLOAD,
}),
[`${BASE}/api/profile`]: () => ({ status: 200, body: PROFILE_PAYLOAD }),
[`${BASE}/api/config`]: () => ({ status: 200, body: CONFIG_PAYLOAD }),
};
}
describe("GeneratedApiClient live surfaces (T-3.9)", () => {
it("getReportLibrary maps the camelCase read-model payload 1:1", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const library = await client.getReportLibrary();
expect(library.folders).toHaveLength(1);
expect(library.folders[0]?.id).toBe("deep-report-battery");
expect(library.reports).toHaveLength(1);
expect(library.reports[0]).toEqual({
id: "rep-runaaaa1",
runId: "runaaaa1",
title: "Battery Advances",
question: "What about battery research?",
methodologySlug: "deep-report",
category: "deep-report-battery",
generatedAt: "2026-09-20T10:05:00Z",
citationCount: 1,
});
});
it("getReportConnections maps kind + label edges", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const edges = await client.getReportConnections();
expect(edges).toEqual([
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runbbbb1",
kind: "shared-sources",
label: "2 shared sources",
},
{
fromReportId: "rep-runaaaa1",
toReportId: "rep-runcccc1",
kind: "related-topic",
label: "Adjacent topic",
},
]);
});
it("getProjectGraph maps nodes + edges", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const graph = await client.getProjectGraph();
expect(graph.nodes[0]?.topic).toBe("Battery Advances");
expect(graph.nodes[0]?.linkedSources).toEqual(["https://example.org/x"]);
expect(graph.edges[0]?.sharedSourceCount).toBe(2);
});
it("getTaskInbox maps derived statuses (G-109: open|done only)", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const inbox = await client.getTaskInbox();
expect(inbox).toEqual([
{
id: "runaaaa1#0",
question: "What is the research question?",
sourceRunId: "runaaaa1",
status: "open",
},
{
id: "runbbbb1#0",
question: "An answered question",
sourceRunId: "runbbbb1",
status: "done",
},
]);
});
it("getSuggestedQueries maps open items with source titles; honest empty", async () => {
const withItems = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const suggestions = await withItems.getSuggestedQueries();
expect(suggestions[0]?.sourceReportTitle).toBe("Deep Report — an in-flight run");
expect(suggestions[0]?.id).toBe("sug-runcccc1#0");
// A fresh user gets an honest empty array — never synthetic content.
const empty = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/suggestions`]: () => ({ status: 200, body: [] }),
}),
);
expect(await empty.getSuggestedQueries()).toEqual([]);
});
it("getUserProfile maps GET /api/profile into the DisplayProfile", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const profile = await client.getUserProfile();
expect(profile.name).toBe("Ada Owner");
expect(profile.initials).toBe("AO");
expect(profile.tagline).toBe("owner@example.org");
});
it("getConnectionStatus maps GET /api/config through the five slots (P2-verify closure)", async () => {
const client = new GeneratedApiClient(stubRoutes(surfaceRoutes()));
const slots = await client.getConnectionStatus();
expect(slots.map((s) => s.slot)).toEqual([
"model",
"search",
"storage",
"memory",
"secrets",
]);
const model = slots[0];
expect(model?.status).toBe("connected"); // tier "user" — not keyless
expect(model?.resolvedTier).toBe("user");
expect(model?.keyStore).toBe("enabled");
expect(model?.keyStatus?.masked).toBe("…7f2a");
expect(model?.detail).toContain("OllamaModelAdapter");
// Storage is server-managed, display-only (D-111).
expect(slots[2]?.detail).toContain("/var/lib/researchx/runs");
});
it("getSettings composes the v0.1 shape live from config + catalog", async () => {
const client = new GeneratedApiClient(
stubRoutes({
...surfaceRoutes(),
[`${BASE}/api/methodologies`]: () => ({
status: 200,
body: [
{
slug: "rapid-answer",
name: "Rapid Answer",
description: "One pass, one memo.",
eta_minutes: 2,
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest.",
output_shape: "memo",
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
{
slug: "deep-report",
name: "Deep Report",
description: "Full depth.",
eta_minutes: 12,
cost_estimate: "~25k tokens (estimate)",
why_recommended: "Thorough.",
output_shape: "report",
dials: {
source_universe: "web+academic",
search_routing: "adaptive",
query_strategy: "decomposed",
breadth: 2,
depth: 3,
quality_gates: "strict",
citation_rigor: "per-claim",
verification_passes: 2,
synthesis_style: "executive",
output_structure: "report",
memory_scope: "none",
checkpoints: true,
},
},
],
}),
}),
);
const settings = await client.getSettings();
// The recommended card is the first in catalog order here — deep-report
// drives the resolved default (index 0 = recommended per the mapping).
expect(settings.defaultMethodologySlug).toBe("rapid-answer");
expect(settings.storageDirectory).toBe("/var/lib/researchx/runs");
// Masked-only key status — never the raw key.
expect(settings.apiKeys[0]?.configured).toBe(true);
expect(settings.apiKeys[0]?.maskedValue).toBe("…7f2a");
expect(settings.theme).toBe("paper");
});
it("saveSettings answers with the authoritative server read (D-111)", async () => {
const client = new GeneratedApiClient(
stubRoutes({
...surfaceRoutes(),
[`${BASE}/api/methodologies`]: () => ({
status: 200,
body: [
{
slug: "rapid-answer",
name: "Rapid Answer",
description: "One pass, one memo.",
eta_minutes: 2,
cost_estimate: "~5k tokens (estimate)",
why_recommended: "Fastest.",
output_shape: "memo",
dials: {
source_universe: "web",
search_routing: "single",
query_strategy: "decomposed",
breadth: 1,
depth: 1,
quality_gates: "standard",
citation_rigor: "per-claim",
verification_passes: 1,
synthesis_style: "memo",
output_structure: "brief",
memory_scope: "none",
checkpoints: false,
},
},
],
}),
}),
);
// The server owns every v0.1 field live — a save is a re-read,
// never a client-side pretend-persist.
const saved = await client.saveSettings({ storageDirectory: "~/elsewhere" });
expect(saved.storageDirectory).toBe("/var/lib/researchx/runs");
});
});
/* ------------------------------------------------------------------ */
/* Refine — real POSTs, honest 503 (T-3.6/T-3.9) */
/* ------------------------------------------------------------------ */
describe("GeneratedApiClient refine (live binding, REQ-80)", () => {
it("getRefinementQuestions POSTs the draft with CSRF + credentials", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/questions`]: (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(init?.method).toBe("POST");
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
const parsed = JSON.parse(body ?? "null") as { draft: string };
expect(parsed.draft).toBe("How do batteries work?");
return { status: 200, body: { questions: ["Which scope?", "What decision?"] } };
},
}),
);
const questions = await client.getRefinementQuestions("How do batteries work?");
expect(questions).toHaveLength(2);
expect(questions[0]?.prompt).toBe("Which scope?");
expect(questions[0]?.optional).toBe(false); // the first question is required
expect(questions[1]?.optional).toBe(true); // the rest are skippable
});
it("getRefinementQuestions 503 → honest error, never a hang, never synthetic", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/questions`]: () => ({
status: 503,
body: { detail: "no local endpoint and no key — tier unavailable" },
}),
}),
);
await expect(client.getRefinementQuestions("draft")).rejects.toThrow(
/refine is unavailable/i,
);
});
it("composeRefinedQuestion POSTs ordered answers with CSRF + credentials", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/compose`]: (
_url: string,
init?: RequestInit,
body?: string,
) => {
expect(headersOf(init)["x-researchx-request"]).toBe("1");
expect(init?.credentials).toBe("include");
const parsed = JSON.parse(body ?? "null") as {
draft: string;
answers: string[];
};
// Answers travel in exchange order, trimmed.
expect(parsed.draft).toBe("How do batteries work?");
expect(parsed.answers).toEqual(["automotive", "supplier choice", ""]);
return {
status: 200,
body: {
question: "How do batteries work? — automotive focus",
context: "Feeds a supplier choice.",
},
};
},
}),
);
const refined = await client.composeRefinedQuestion("How do batteries work?", {
"q-1": "automotive",
"q-2": "supplier choice",
"q-3": "",
});
expect(refined.question).toBe("How do batteries work? — automotive focus");
expect(refined.context).toBe("Feeds a supplier choice.");
});
it("composeRefinedQuestion 503 → honest error", async () => {
const client = new GeneratedApiClient(
stubRoutes({
[`${BASE}/api/refine/compose`]: () => ({
status: 503,
body: { detail: "planner unreachable" },
}),
}),
);
await expect(
client.composeRefinedQuestion("draft", { "q-1": "answer" }),
).rejects.toThrow(/refine is unavailable/i);
});
});
/* ------------------------------------------------------------------ */
/* D-112: the delegation is DEAD — structural proof */
/* ------------------------------------------------------------------ */
describe("GeneratedApiClient standalone (D-112)", () => {
it("has no MockApiClient in its prototype chain — the delegation is gone", () => {
const client = new GeneratedApiClient();
expect(client).not.toBeInstanceOf(MockApiClient);
let proto = Object.getPrototypeOf(client);
while (proto !== null) {
expect(proto.constructor.name).not.toBe("MockApiClient");
proto = Object.getPrototypeOf(proto);
}
});
it("implements every ApiClient surface as its own methods", () => {
const client = new GeneratedApiClient();
const required = [
"getMethodologies",
"createRun",
"getRun",
"approvePlan",
"cancelRun",
"pauseRun",
"resumeRun",
"getRunEvents",
"getReport",
"getLedger",
"exportReport",
"getProjectGraph",
"getTaskInbox",
"getReportLibrary",
"getReportConnections",
"getUserProfile",
"getConnectionStatus",
"getSettings",
"saveSettings",
"getUserSettings",
"saveUserSettings",
"setModelKey",
"getConfigStatus",
"getSuggestedQueries",
"getRefinementQuestions",
"composeRefinedQuestion",
"login",
"logout",
"getSession",
"listUsers",
"createUser",
"deactivateUser",
] as const;
for (const method of required) {
expect(
typeof (client as unknown as Record<string, unknown>)[method],
).toBe("function");
}
});
});