diff --git a/backend/src/researchx/api/app.py b/backend/src/researchx/api/app.py index 200520a..912c39d 100644 --- a/backend/src/researchx/api/app.py +++ b/backend/src/researchx/api/app.py @@ -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) diff --git a/backend/src/researchx/api/read_models.py b/backend/src/researchx/api/read_models.py new file mode 100644 index 0000000..e3e6802 --- /dev/null +++ b/backend/src/researchx/api/read_models.py @@ -0,0 +1,505 @@ +"""Disk read-models (T-3.2, D-114, D-115 — REQ-76..79). + +Pure functions over ``runs///`` 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 — "#" 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", +] \ No newline at end of file diff --git a/backend/src/researchx/auth/users.py b/backend/src/researchx/auth/users.py index 18bf935..d32133b 100644 --- a/backend/src/researchx/auth/users.py +++ b/backend/src/researchx/auth/users.py @@ -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. diff --git a/backend/src/researchx/engine/actor.py b/backend/src/researchx/engine/actor.py index 0f99b24..171d8b2 100644 --- a/backend/src/researchx/engine/actor.py +++ b/backend/src/researchx/engine/actor.py @@ -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}) diff --git a/backend/src/researchx/engine/artifacts.py b/backend/src/researchx/engine/artifacts.py new file mode 100644 index 0000000..2f17e0f --- /dev/null +++ b/backend/src/researchx/engine/artifacts.py @@ -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", +] \ No newline at end of file diff --git a/backend/tests/test_artifacts.py b/backend/tests/test_artifacts.py new file mode 100644 index 0000000..d4ffb75 --- /dev/null +++ b/backend/tests/test_artifacts.py @@ -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() \ No newline at end of file diff --git a/backend/tests/test_live_routes.py b/backend/tests/test_live_routes.py new file mode 100644 index 0000000..b188ee8 --- /dev/null +++ b/backend/tests/test_live_routes.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_read_models.py b/backend/tests/test_read_models.py new file mode 100644 index 0000000..7cf4725 --- /dev/null +++ b/backend/tests/test_read_models.py @@ -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 # 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) == [] \ No newline at end of file diff --git a/frontend/openapi.json b/frontend/openapi.json index efecd29..9080916 100644 --- a/frontend/openapi.json +++ b/frontend/openapi.json @@ -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": { diff --git a/frontend/src/components/library-view.tsx b/frontend/src/components/library-view.tsx index ddf2adb..27c568e 100644 --- a/frontend/src/components/library-view.tsx +++ b/frontend/src/components/library-view.tsx @@ -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[] }) {

Open questions worth following up · {openCount} open

-

- Toggles are local-only — the inbox persists in v0.2. -

+ {liveMode ? ( +

+ A question is answered when its research run completes — status + follows the run. +

+ ) : ( +

+ Toggles are local-only — the inbox persists in v0.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)]" /> vi.fn()); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); + +const state = vi.hoisted(() => ({ + api: {} as Record, + liveMode: false, +})); +vi.mock("@/lib/api/client", async (importOriginal) => { + const actual = await importOriginal< + Promise + >(); + 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 { + 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(); + + // 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect( + screen.getByText(/No open questions yet — they collect here as you run research/i), + ).toBeInTheDocument(); + }); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/reading-room-live-parity.test.tsx b/frontend/src/components/reading-room-live-parity.test.tsx new file mode 100644 index 0000000..3d54ff6 --- /dev/null +++ b/frontend/src/components/reading-room-live-parity.test.tsx @@ -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, +})); +vi.mock("@/lib/api/client", async (importOriginal) => { + const actual = await importOriginal< + Promise + >(); + 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 = { + [`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 { + const client = new GeneratedApiClient(liveStackFetch()); + return client as unknown as Record; +} + +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(); + + // 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(); + + 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(); + + 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; + + render(); + + // 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; + }; + 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); + }); +}); \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 5a5f526..7fc02c7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -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 { @@ -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(); } diff --git a/frontend/src/lib/api/generated/schema.d.ts b/frontend/src/lib/api/generated/schema.d.ts index ba18ec6..c951fd0 100644 --- a/frontend/src/lib/api/generated/schema.d.ts +++ b/frontend/src/lib/api/generated/schema.d.ts @@ -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; 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"]; + }; + }; + }; + }; } diff --git a/frontend/src/lib/api/live-client.test.ts b/frontend/src/lib/api/live-client.test.ts index ba9d450..4d60582 100644 --- a/frontend/src/lib/api/live-client.test.ts +++ b/frontend/src/lib/api/live-client.test.ts @@ -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): 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): typeof globalThis.fet }) as typeof globalThis.fetch; } +/** Headers actually sent on a call (CSRF + cookie assertions). */ +function headersOf(init?: RequestInit): Record { + const raw = init?.headers; + const record: Record = {}; + 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> = []; + 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 () => { diff --git a/frontend/src/lib/api/live-client.ts b/frontend/src/lib/api/live-client.ts index 704fbc7..4fc39ef 100644 --- a/frontend/src/lib/api/live-client.ts +++ b/frontend/src/lib/api/live-client.ts @@ -1,15 +1,22 @@ /** - * GeneratedApiClient — the live-mode ApiClient binding (v0.2 P3, REQ-26/REQ-54). + * GeneratedApiClient — the standalone live-mode ApiClient (v0.3 P3, + * T-3.8, D-112 — the delegation ends). * - * Core research loop methods (methodologies, runs, events, report, - * ledger, export) hit the FastAPI backend through openapi-fetch, - * typed by the generated schema. Per G-002, the remaining prototype - * surfaces (library, settings, suggestions, refine, graph, inbox, - * profile) stay mock-backed until v0.3+ — this class delegates those - * to the MockApiClient rather than reimplementing them. + * A STANDALONE class implementing ApiClient + RunLifecycle: every + * surface hits the FastAPI backend through openapi-fetch, typed by the + * generated schema. No method delegates to MockApiClient (REQ-75's + * delegation-end half); the mock stays a separate dev/test fixture. + * + * Request discipline (C3-05), enforced at ONE injection point — the + * private `#live` wrapper every call routes through: + * - `credentials: "include"` on EVERY call — the session cookie + * rides along everywhere, GETs included; + * - `X-ResearchX-Request: "1"` on EVERY mutation (POST/PUT) — the + * backend's guard rejects a non-GET without it (403). + * EventSource is constructed with `{withCredentials: true}`. * * Components never import this class — only client.ts (the boundary) - * does, selected by RESEARCHX_API_MODE=live (mock is the default). + * does, selected by RESEARCHX_API_MODE=live (mock stays the default). */ import createClient from "openapi-fetch"; @@ -18,35 +25,61 @@ import type { paths } from "@/lib/api/generated/schema"; import type { CitationLedger, ConfigStatus, + ConnectionSlot, CreateUserInput, + DisplayProfile, + InboxItem, Methodology, ModelKeyStatus, + ProjectGraph, + RefinementQuestion, + RefinedQuestion, Report, + ReportConnection, + ReportLibrary, ResearchRun, + Settings, + SuggestedQuery, TraceEvent, UserSettings, UserProfile, } from "@/lib/types"; import { KeyStoreDisabledError } from "@/lib/types"; import type { + ApiClient, CreateRunInput, RunEventSubscription, + RunLifecycle, } from "@/lib/api/client"; import { AuthError } from "@/lib/api/client"; -import { MockApiClient } from "@/lib/api/mock/mock-client"; function baseUrl(): string { - return ( - process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000" - ); + return process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000"; } /** - * The uniform CSRF header value for every mutating request (C3-05): - * the backend guard requires `X-ResearchX-Request` on non-GET calls. + * The uniform CSRF header for every mutating request (C3-05): the + * backend guard requires `X-ResearchX-Request` on non-GET calls. */ const CSRF_HEADER = "X-ResearchX-Request"; +/** Per-init options applying the wire discipline to a fetch init. */ +type FetchInit = Record; + +/* ------------------------------------------------------------------ */ +/* Backend wire shapes (snake_case) the mappings below absorb. */ +/* ------------------------------------------------------------------ */ + +/** Backend UserPublic → frontend UserProfile (1:1). */ +type BackendUser = { + user_id: string; + email: string; + display_name: string; + role: string; + active: boolean; + created_at: string; +}; + type BackendRun = { id: string; status: string; @@ -71,33 +104,162 @@ type BackendCard = { dials: Record; }; -export class GeneratedApiClient extends MockApiClient { +/** report.json as persisted at DONE (engine/artifacts.py, ids pN). */ +type BackendReportArtifact = { + v: number; + run_id: string; + owner_id?: string | null; + title: string; + question: string; + methodology_slug: string; + generated_at: string; + sections: Array<{ heading: string; body: string }>; + citations: Array<{ + passage_id: string; + url: string; + title: string; + quote: string; + health: string; + authority_tier: string; + }>; +}; + +/** evidence.json — every stored passage, ids in the normalized pN form. */ +type BackendEvidenceArtifact = { + v: number; + run_id: string; + passages: Array<{ + passage_id: string; + url: string; + title: string; + quote: string; + text: string; + }>; +}; + +/** GET /api/runs/{id}/report — markdown always, artifacts when present. */ +type BackendReportEnvelope = { + run_id: string; + markdown: string; + report: BackendReportArtifact | null; + evidence: BackendEvidenceArtifact | null; +}; + +/** ledger.json as persisted at DONE (trust/ledger.py, ids p-N). */ +type BackendLedger = { + run_id: string; + generated_at: string; + entries: Array<{ + url: string; + visited_at: string; + passages: string[]; + claims_supported: number; + health: string; + authority_tier: string; + }>; + checklist?: Record; +}; + +/* ------------------------------------------------------------------ */ +/* Shared mapping helpers */ +/* ------------------------------------------------------------------ */ + +/** URL → host, the Citation.site convention ("https://a.example/x" → "a.example"). */ +function siteOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} + +/** health vocabulary → frontend CitationHealth; anything unknown is dead. */ +function toHealth(health: string): "live" | "stale" | "dead" { + if (health === "live" || health === "stale") return health; + return "dead"; +} + +/** authority tier: official→1, reference→2, everything else→3 (T-3.10). */ +function toAuthorityTier(tier: string): 1 | 2 | 3 { + if (tier === "official") return 1; + if (tier === "reference") return 2; + return 3; +} + +/** `p-N` → `pN` — evidence ids are already normalized, ledger ids are not. */ +function normalizePassageId(id: string): string { + return id.replace(/^p-(\d+)$/, "p$1"); +} + +/** snake_case dial values → the frontend DialValues camelCase shape. */ +function toDials(dials: Record): Methodology["defaultDials"] { + return { + sourceUniverse: String(dials.source_universe ?? "web"), + searchRouting: String(dials.search_routing ?? "web"), + queryStrategy: String(dials.query_strategy ?? "targeted"), + breadth: Number(dials.breadth ?? 1), + depth: Number(dials.depth ?? 1), + qualityGates: String(dials.quality_gates ?? "standard"), + citationRigor: String(dials.citation_rigor ?? "per-claim"), + verificationPasses: Number(dials.verification_passes ?? 0), + synthesisStyle: String(dials.synthesis_style ?? "memo"), + outputStructure: String(dials.output_structure ?? "brief"), + memoryScope: String(dials.memory_scope ?? "none"), + // The backend models checkpoints as a bool; the frontend vocabulary + // is "plan-only" | "plan+draft". v0.3's only human stop is the plan + // gate either way, so both values map to "plan-only". + checkpoints: "plan-only", + }; +} + +/** Two-letter initials from a display name ("Dev User" → "DU"). */ +function initialsOf(displayName: string): string { + const words = displayName.trim().split(/\s+/).filter(Boolean); + if (words.length === 0) return "··"; + const first = words[0]?.[0] ?? ""; + const second = words[1]?.[0] ?? words[0]?.[1] ?? ""; + return `${first}${second}`.toUpperCase(); +} + +export class GeneratedApiClient implements ApiClient, RunLifecycle { #client: ReturnType>; + /** + * DONE report envelopes, memoized per run id: once report.json + * exists the artifacts on disk are final (a terminal run is never + * rewritten), so the Reading Room's report+ledger double-fetch and + * the export round-trip share one network read per run. + */ + #reportCache = new Map(); constructor(fetchImpl?: typeof globalThis.fetch) { - super(); this.#client = createClient({ baseUrl: baseUrl(), ...(fetchImpl ? { fetch: fetchImpl } : {}), }); } + /** + * THE injection point for the wire discipline (T-3.8): cookie on + * every call, CSRF header on every mutation. Every request below + * routes through here — the discipline can never be forgotten at a + * call site. + */ + #live(init: T, mutation: boolean): T { + return { + ...init, + credentials: "include", + ...(mutation ? { headers: { [CSRF_HEADER]: "1" } } : {}), + }; + } + /* ---------------------------------------------------------------- */ - /* Session surface (T-1.10, REQ-69/REQ-70). Fetch discipline: */ - /* - `credentials: "include"` on EVERY call — the session cookie */ - /* rides along; */ - /* - `X-ResearchX-Request: "1"` on every mutation (C3-05). */ + /* Session surface (P1, REQ-69/REQ-70). The P1-verify P2 flag closes */ + /* here: EVERY mutation in this client — core loop included — rides */ + /* the same credentials + CSRF discipline through #live. */ /* ---------------------------------------------------------------- */ /** Map the backend's UserPublic to the frontend UserProfile shape. */ - #toProfile(user: { - user_id: string; - email: string; - display_name: string; - role: string; - active: boolean; - created_at: string; - }): UserProfile { + #toProfile(user: BackendUser): UserProfile { return { user_id: user.user_id, email: user.email, @@ -108,14 +270,10 @@ export class GeneratedApiClient extends MockApiClient { }; } - override async login(email: string, password: string): Promise { + async login(email: string, password: string): Promise { const { data, error, response } = await this.#client.POST( "/api/auth/login", - { - body: { email, password }, - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }, + this.#live({ body: { email, password } }, true), ); if (error || !data) { // Uniform 401 — the backend never enumerates users; neither do we. @@ -124,219 +282,88 @@ export class GeneratedApiClient extends MockApiClient { } throw new AuthError("login failed", response.status); } - return this.#toProfile(data); + return this.#toProfile(data as unknown as BackendUser); } - override async logout(): Promise { - const { error } = await this.#client.POST("/api/auth/logout", { - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }); + async logout(): Promise { + const { error } = await this.#client.POST( + "/api/auth/logout", + this.#live({}, true), + ); if (error) { throw new Error("live: logout failed"); } } - override async getSession(): Promise { - const { data, error } = await this.#client.GET("/api/auth/me", { - credentials: "include", - }); + async getSession(): Promise { + const { data, error } = await this.#client.GET( + "/api/auth/me", + this.#live({}, false), + ); if (error || !data) { // 401 → null: "no session" is a state, not an error. return null; } - return this.#toProfile(data); + return this.#toProfile(data as unknown as BackendUser); } - override async listUsers(): Promise { - const { data, error } = await this.#client.GET("/api/admin/users", { - credentials: "include", - }); + async listUsers(): Promise { + const { data, error } = await this.#client.GET( + "/api/admin/users", + this.#live({}, false), + ); if (error || !data) { throw new Error("live: failed to list users"); } - return data.map((user) => this.#toProfile(user)); + return (data as unknown[]).map((user) => this.#toProfile(user as BackendUser)); } - override async createUser(input: CreateUserInput): Promise { + async createUser(input: CreateUserInput): Promise { const { data, error, response } = await this.#client.POST( "/api/admin/users", - { - body: { - email: input.email, - password: input.password, - display_name: input.display_name ?? "", - role: input.role ?? "user", + this.#live( + { + body: { + email: input.email, + password: input.password, + display_name: input.display_name ?? "", + role: input.role ?? "user", + }, }, - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }, + true, + ), ); if (error || !data) { throw new Error(`live: failed to create user (${response.status})`); } - return this.#toProfile(data); + return this.#toProfile(data as unknown as BackendUser); } - override async deactivateUser(user_id: string): Promise { - const { data, error } = await this.#client.POST( + async deactivateUser(user_id: string): Promise { + const { data, error, response } = await this.#client.POST( "/api/admin/users/{user_id}/deactivate", - { - params: { path: { user_id } }, - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }, + this.#live({ params: { path: { user_id } } }, true), ); if (error || !data) { - throw new Error(`live: failed to deactivate user ${user_id}`); + throw new Error(`live: failed to deactivate user ${user_id} (${response.status})`); } - return this.#toProfile(data); + return this.#toProfile(data as unknown as BackendUser); } /* ---------------------------------------------------------------- */ - /* v0.3 P2 per-user settings + model keys (T-2.9, REQ-72..74). */ - /* Same fetch discipline as the session surface: credentials */ - /* "include" everywhere, CSRF header on every mutation. The raw */ - /* model key crosses the wire exactly once (the PUT body) and is */ - /* never echoed — only the masked KeyStatus comes back. */ + /* Core research loop (v0.2 P3, REQ-26) — now with the full request */ + /* discipline on every mutation (T-3.8 closes the P1-verify flag). */ /* ---------------------------------------------------------------- */ - /** Map the backend's UserSettings to the frontend shape (1:1). */ - #toUserSettings(payload: Record): UserSettings { - return { - planner_model: String(payload.planner_model ?? "gpt-oss:20b"), - writer_model: String(payload.writer_model ?? "gpt-oss:20b"), - methodology_defaults: - (payload.methodology_defaults as UserSettings["methodology_defaults"]) ?? {}, - appearance: String(payload.appearance ?? "system"), - }; - } - - /** Map the backend's KeyStatus — masked-only, never the raw key. */ - #toKeyStatus(payload: Record): ModelKeyStatus { - return { - configured: Boolean(payload.configured), - masked: payload.masked == null ? null : String(payload.masked), - disabled: Boolean(payload.disabled ?? false), - reason: payload.reason == null ? null : String(payload.reason), - }; - } - - override async getUserSettings(): Promise { - const { data, error } = await this.#client.GET("/api/config/settings", { - credentials: "include", - }); - if (error || !data) { - throw new Error("live: failed to load settings"); - } - return this.#toUserSettings(data as Record); - } - - override async saveUserSettings( - update: Partial, - ): Promise { - // Partial body: only the fields the caller set travel on the wire. - const body: Record = {}; - if (update.planner_model !== undefined) { - body.planner_model = update.planner_model; - } - if (update.writer_model !== undefined) { - body.writer_model = update.writer_model; - } - if (update.methodology_defaults !== undefined) { - body.methodology_defaults = update.methodology_defaults; - } - if (update.appearance !== undefined) { - body.appearance = update.appearance; - } - const { data, error, response } = await this.#client.PUT( - "/api/config/settings", - { - body, - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }, + async getMethodologies(): Promise { + const { data, error } = await this.#client.GET( + "/api/methodologies", + this.#live({}, false), ); - if (error || !data) { - throw new Error(`live: failed to save settings (${response.status})`); - } - return this.#toUserSettings(data as Record); - } - - override async setModelKey(key: string): Promise { - const { data, error, response } = await this.#client.PUT( - "/api/config/settings/model-key", - { - body: { key }, - credentials: "include", - headers: { [CSRF_HEADER]: "1" }, - }, - ); - if (error || !data) { - // 409: the server's key store is disabled (no KEK) — surface the - // honest reason from the backend's detail payload, never a - // silent no-op and never the raw key in any error copy. - if (response.status === 409) { - // Backend shape: {detail: {key_store, reason}} (FastAPI wraps - // the error detail); openapi-fetch hands us the parsed body. - const payload = error as unknown; - const detail = - typeof payload === "object" && - payload !== null && - "detail" in payload && - typeof (payload as { detail: unknown }).detail === "object" - ? ((payload as { detail: Record }).detail) - : (payload as Record); - const rawReason = - typeof detail === "string" - ? detail - : detail && typeof detail === "object" - ? (detail as { reason?: unknown }).reason - : undefined; - const reason = - (typeof rawReason === "string" && rawReason) || - (typeof detail === "object" && detail !== null - ? String((detail as { detail?: unknown }).detail ?? "key store disabled") - : "key store disabled"); - throw new KeyStoreDisabledError(reason); - } - throw new Error(`live: failed to store model key (${response.status})`); - } - return this.#toKeyStatus(data as Record); - } - - override async getConfigStatus(): Promise { - const { data, error } = await this.#client.GET("/api/config", { - credentials: "include", - }); - if (error || !data) { - throw new Error("live: failed to load config status"); - } - const backend = data as Record; - const keyStatus = backend.key_status as Record | undefined; - const counters = - (backend.cost_counters as Record | undefined) ?? {}; - return { - resolved_tier: (backend.resolved_tier as ConfigStatus["resolved_tier"]) ?? "keyless", - key_store: (backend.key_store as ConfigStatus["key_store"]) ?? "disabled", - key_status: this.#toKeyStatus(keyStatus ?? {}), - model_adapter: String(backend.model_adapter ?? "unknown"), - search_adapter: String(backend.search_adapter ?? "unknown"), - storage_dir: String(backend.storage_dir ?? ""), - probe: (backend.probe as Record | null | undefined) ?? null, - cost_counters: { - runs_created: Number(counters.runs_created ?? 0), - runs_completed: Number(counters.runs_completed ?? 0), - }, - }; - } - - override async getMethodologies(): Promise { - const { data, error } = await this.#client.GET("/api/methodologies"); if (error || !data) { throw new Error("live: failed to load methodologies"); } - return (data as BackendCard[]).map((card, index) => ({ + return (data as unknown as BackendCard[]).map((card, index) => ({ slug: card.slug, name: card.name, tagline: card.description.split(".")[0] ?? card.description, @@ -347,9 +374,9 @@ export class GeneratedApiClient extends MockApiClient { recommended: index === 0, verification: card.slug === "deep-report" ? "full" : "standard", dialSummary: Object.entries(card.dials).map( - ([dial, value]) => `${dial}: ${String(value)}`, + ([dial, value]) => `${dial.replace(/_/g, " ")}: ${String(value)}`, ), - defaultDials: card.dials as unknown as Methodology["defaultDials"], + defaultDials: toDials(card.dials), badge: card.slug === "rapid-answer" ? "Fastest" : undefined, })); } @@ -371,87 +398,105 @@ export class GeneratedApiClient extends MockApiClient { }; } - override async createRun(input: CreateRunInput): Promise { + async createRun(input: CreateRunInput): Promise { const overrides: Record = {}; for (const [key, value] of Object.entries(input.dialOverrides ?? {})) { const snake = key.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`); overrides[snake] = value; } - const { data, error } = await this.#client.POST("/api/runs", { - body: { - question: input.question, - context: input.context ?? "", - methodology: input.methodologySlug, - dial_overrides: overrides, - }, - }); + const { data, error, response } = await this.#client.POST( + "/api/runs", + this.#live( + { + body: { + question: input.question, + context: input.context ?? "", + methodology: input.methodologySlug, + dial_overrides: overrides, + }, + }, + true, + ), + ); if (error || !data) { + // 409: a corrupt stored key envelope is an honest failure with + // its reason (P2) — surfaced, never a silent tier drop. + if (response.status === 409) { + const detail = (error as { detail?: unknown })?.detail; + throw new Error( + `live: ${typeof detail === "string" ? detail : "run refused (409)"}`, + ); + } throw new Error("live: failed to create run"); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } - override async getRun(id: string): Promise { - const { data, error } = await this.#client.GET("/api/runs/{run_id}", { - params: { path: { run_id: id } }, - }); + async getRun(id: string): Promise { + const { data, error } = await this.#client.GET( + "/api/runs/{run_id}", + this.#live({ params: { path: { run_id: id } } }, false), + ); if (error || !data) { throw new Error(`live: failed to load run ${id}`); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } - override async approvePlan(id: string): Promise { + async approvePlan(id: string): Promise { const { data, error } = await this.#client.POST( "/api/runs/{run_id}/approve", - { params: { path: { run_id: id } }, body: {} }, + this.#live({ params: { path: { run_id: id } }, body: {} }, true), ); if (error || !data) { throw new Error(`live: failed to approve run ${id}`); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } - override async cancelRun(id: string): Promise { + async cancelRun(id: string): Promise { const { data, error } = await this.#client.POST( "/api/runs/{run_id}/cancel", - { params: { path: { run_id: id } } }, + this.#live({ params: { path: { run_id: id } } }, true), ); if (error || !data) { throw new Error(`live: failed to cancel run ${id}`); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } async pauseRun(id: string): Promise { const { data, error } = await this.#client.POST( "/api/runs/{run_id}/pause", - { params: { path: { run_id: id } } }, + this.#live({ params: { path: { run_id: id } } }, true), ); if (error || !data) { throw new Error(`live: failed to pause run ${id}`); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } async resumeRun(id: string): Promise { const { data, error } = await this.#client.POST( "/api/runs/{run_id}/resume", - { params: { path: { run_id: id } } }, + this.#live({ params: { path: { run_id: id } } }, true), ); if (error || !data) { throw new Error(`live: failed to resume run ${id}`); } - return this.#toRun(data as BackendRun); + return this.#toRun(data as unknown as BackendRun); } - override getRunEvents( + getRunEvents( id: string, onEvent: (event: TraceEvent) => void, onDone?: () => void, ): RunEventSubscription { + // withCredentials: the SSE stream authenticates with the session + // cookie (EventSource GET is CSRF-exempt by the backend guard). const source = new EventSource( `${baseUrl()}/api/runs/${encodeURIComponent(id)}/events`, + { withCredentials: true }, ); let paused = false; let buffer: TraceEvent[] = []; @@ -547,106 +592,611 @@ export class GeneratedApiClient extends MockApiClient { }; } - override async getReport(id: string): Promise { + /* ---------------------------------------------------------------- */ + /* Reading Room fidelity (T-3.10, REQ-82) — structured report with */ + /* REAL quotes, ledger with real entries, honest exports (the P-12 */ + /* fabrication dies here). */ + /* ---------------------------------------------------------------- */ + + /** Fetch the full report envelope; DONE envelopes are memoized. */ + async #reportEnvelope(id: string): Promise { + const cached = this.#reportCache.get(id); + if (cached !== undefined) return cached; const { data, error } = await this.#client.GET( "/api/runs/{run_id}/report", - { params: { path: { run_id: id } } }, + this.#live({ params: { path: { run_id: id } } }, false), ); if (error || !data) { throw new Error(`live: report not ready for run ${id}`); } - const markdown = (data as { run_id: string; markdown: string }).markdown; + const payload = data as unknown as BackendReportEnvelope; + // Memoize ONLY once report.json exists — a terminal run's + // artifacts are final; an in-flight run's are not. + if (payload.report !== null && payload.report !== undefined) { + this.#reportCache.set(id, payload); + } + return payload; + } + + async getReport(id: string): Promise { + const payload = await this.#reportEnvelope(id); + const artifact = payload.report; + if (artifact !== null && artifact !== undefined) { + // Structured artifact at DONE: sections carry normalized [^pN] + // markers (report-body.ts parses them with zero changes — + // C3-10) and citations carry the REAL stored quotes — hover + // shows exactly the passage the engine anchored. + return { + id: `${id}-report`, + runId: id, + title: artifact.title, + sections: artifact.sections.map((section) => ({ + heading: section.heading, + body: section.body, + })), + citations: artifact.citations.map((citation, index) => ({ + id: `c-${index + 1}`, + url: citation.url, + title: citation.title, + site: siteOf(citation.url), + passageId: citation.passage_id, + quote: citation.quote, + verification: toHealth(citation.health), + authorityTier: toAuthorityTier(citation.authority_tier), + })), + }; + } + // Pre-artifact DONE run (D-106): markdown-only fallback — the + // served markdown verbatim, one section, no fabricated citations. return { - id, + id: `${id}-report`, runId: id, title: "Research Report", - sections: [{ heading: "Report", body: markdown }], + sections: [{ heading: "Report", body: payload.markdown }], citations: [], }; } - override async getLedger(id: string): Promise { + /** passageId → {quote, title} from the run's stored artifacts. */ + async #passageQuotes(id: string): Promise> { + const byId = new Map(); + const envelope = await this.#reportEnvelope(id).catch(() => null); + for (const citation of envelope?.report?.citations ?? []) { + byId.set(citation.passage_id, { quote: citation.quote, title: citation.title }); + } + for (const passage of envelope?.evidence?.passages ?? []) { + if (!byId.has(passage.passage_id)) { + byId.set(passage.passage_id, { quote: passage.quote, title: passage.title }); + } + } + return byId; + } + + async #rawLedger(id: string): Promise { const { data, error } = await this.#client.GET( "/api/runs/{run_id}/ledger", - { params: { path: { run_id: id } } }, + this.#live({ params: { path: { run_id: id } } }, false), ); if (error || !data) { throw new Error(`live: ledger not ready for run ${id}`); } - const backend = data as { - run_id: string; - generated_at: string; - entries: Array<{ - url: string; - visited_at: string; - passages: string[]; - claims_supported: number; - health: string; - authority_tier: string; - }>; - }; + return data as unknown as BackendLedger; + } + + async getLedger(id: string): Promise { + const [backend, quotes] = await Promise.all([ + this.#rawLedger(id), + this.#passageQuotes(id), + ]); return { runId: backend.run_id, + citations: backend.entries.map((entry, index) => { + const passageId = entry.passages[0] !== undefined + ? normalizePassageId(entry.passages[0]) + : `c${index + 1}`; + const stored = quotes.get(passageId); + return { + id: `c-${index + 1}`, + url: entry.url, + title: stored?.title ?? entry.url, + site: siteOf(entry.url), + passageId, + // REAL quote: the stored passage this ledger entry anchors + // to. An entry whose passage is missing from the artifacts + // keeps an honest empty quote — never an invented one. + quote: stored?.quote ?? "", + verification: toHealth(entry.health), + authorityTier: toAuthorityTier(entry.authority_tier), + }; + }), generatedAt: backend.generated_at, - citations: backend.entries.map((entry, index) => ({ - id: `c-${index + 1}`, - url: entry.url, - title: entry.url, - site: entry.url, - passageId: entry.passages[0] ?? "", - quote: "", - verification: (entry.health === "live" - ? "live" - : entry.health === "stale" - ? "stale" - : "dead") as CitationLedger["citations"][number]["verification"], - authorityTier: (entry.authority_tier === "official" - ? 1 - : entry.authority_tier === "reference" - ? 2 - : 3) as CitationLedger["citations"][number]["authorityTier"], - })), }; } - override async exportReport( + async exportReport( id: string, format: "markdown" | "json", ): Promise<{ filename: string; content: string }> { - const [report, ledger] = await Promise.all([this.getReport(id), this.getLedger(id)]); + const [envelope, backend] = await Promise.all([ + this.#reportEnvelope(id), + this.#rawLedger(id), + ]); + const quotes = await this.#passageQuotes(id); + if (format === "markdown") { - const body = report.sections.map((s) => `## ${s.heading}\n\n${s.body}`).join("\n\n"); - const citationsBlock = ledger.citations - .map( - (c, i) => - `${i + 1}. [${c.url}](${c.url}) — health: ${c.verification}, tier: ${c.authorityTier}`, - ) + // The SERVED report markdown verbatim — raw [p-N] markers and + // all (report.md is never marker-normalized; C3-10) — plus a + // citation block from the REAL ledger entries with real quotes. + const citationsBlock = backend.entries + .map((entry, index) => { + const passageId = entry.passages[0] !== undefined + ? normalizePassageId(entry.passages[0]) + : `c${index + 1}`; + const stored = quotes.get(passageId); + const health = + toHealth(entry.health) === "live" + ? "✓ live" + : toHealth(entry.health) === "stale" + ? "△ stale" + : "✗ dead"; + const quoted = stored !== undefined && stored.quote !== "" + ? `\n > ${stored.quote}` + : ""; + const title = stored?.title ?? entry.url; + return ( + `${index + 1}. [${entry.url}](${entry.url}) — ${title} — ` + + `health: ${health}, tier: ${toAuthorityTier(entry.authority_tier)}${quoted}` + ); + }) .join("\n"); return { filename: `researchx-${id}.md`, content: - `# Research Report\n\n${body}\n\n## Citations\n\n${citationsBlock || "(no citations)"}\n`, + envelope.markdown.trim() + + (citationsBlock === "" ? "" : `\n\n## Citations\n\n${citationsBlock}\n`), }; } + + // JSON export (REQ-34): the REAL ledger shape — every entry keeps + // its own claims_supported, health, tier, passages AND the real + // stored quote. The fabricated `claims_supported: 1` and empty + // quotes are dead (P-12 closure). const payload = { - run_id: id, - generated_at: ledger.generatedAt, - entries: ledger.citations.map((c) => ({ - url: c.url, - visited_at: ledger.generatedAt, - passages: c.passageId ? [c.passageId] : [], - claims_supported: 1, - health: c.verification, - authority_tier: c.authorityTier === 1 ? "official" : c.authorityTier === 2 ? "reference" : "general", - })), - checklist: { - run_id: id, - citations_checked: ledger.citations.length, - }, + run_id: backend.run_id, + generated_at: backend.generated_at, + entries: backend.entries.map((entry) => { + const passageId = entry.passages[0] !== undefined + ? normalizePassageId(entry.passages[0]) + : ""; + return { + url: entry.url, + visited_at: entry.visited_at, + passages: entry.passages, + claims_supported: entry.claims_supported, + health: entry.health, + authority_tier: entry.authority_tier, + quote: quotes.get(passageId)?.quote ?? "", + }; + }), + checklist: + backend.checklist !== undefined && backend.checklist !== null + ? backend.checklist + : { + run_id: backend.run_id, + citations_checked: backend.entries.length, + }, }; return { filename: `researchx-${id}.json`, content: JSON.stringify(payload, null, 2), }; } + + /* ---------------------------------------------------------------- */ + /* Live surfaces (T-3.9, REQ-76..81) — the read-model routes, mapped */ + /* through the same interface/types the components already consume */ + /* (zero rewrites, REQ-54). */ + /* ---------------------------------------------------------------- */ + + async getReportLibrary(): Promise { + const { data, error } = await this.#client.GET( + "/api/library", + this.#live({ params: { query: { sort: "recent" } } }, false), + ); + if (error || !data) { + throw new Error("live: failed to load the report library"); + } + // The backend payload is already the frontend ReportLibrary shape + // (camelCase, field-for-field — read_models.py pins this). + return data as unknown as ReportLibrary; + } + + async getReportConnections(): Promise { + const { data, error } = await this.#client.GET( + "/api/library/connections", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load report connections"); + } + return data as unknown as ReportConnection[]; + } + + async getProjectGraph(): Promise { + const { data, error } = await this.#client.GET( + "/api/graph", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load the project graph"); + } + return data as unknown as ProjectGraph; + } + + async getTaskInbox(): Promise { + const { data, error } = await this.#client.GET( + "/api/tasks", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load the task inbox"); + } + // Live-mode status is DERIVED (D-114/G-109): open unless the + // source run reached DONE — the payload's status is the truth the + // checkbox displays; nothing client-side can flip it. + return (data as unknown[]).map((item) => { + const record = item as Record; + return { + id: String(record.id), + question: String(record.question), + sourceRunId: String(record.sourceRunId), + status: String(record.status) as InboxItem["status"], + }; + }); + } + + async getSuggestedQueries(): Promise { + const { data, error } = await this.#client.GET( + "/api/suggestions", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load suggested queries"); + } + // Never synthetic: the backend joins open items with source titles; + // a fresh user gets an honest empty array (REQ-79). + return (data as unknown[]).map((item) => { + const record = item as Record; + return { + id: String(record.id), + question: String(record.question), + sourceRunId: String(record.sourceRunId), + sourceReportTitle: String(record.sourceReportTitle), + }; + }); + } + + async getRefinementQuestions(draft: string): Promise { + const { data, error, response } = await this.#client.POST( + "/api/refine/questions", + this.#live({ body: { draft } }, true), + ); + if (error || !data) { + // Honest 503 (T-3.6): the resolved tier is unavailable (e.g. a + // keyless user with no local endpoint) — surfaced, never a + // hang, never synthetic questions. + if (response.status === 503) { + throw new Error( + "live: refine is unavailable — the model tier resolved for your account cannot be reached", + ); + } + throw new Error("live: failed to load refinement questions"); + } + const questions = (data as { questions?: unknown[] }).questions ?? []; + return questions.map((prompt, index) => ({ + id: `q-${index + 1}`, + prompt: String(prompt), + placeholder: "one short answer", + // The backend contract does not mark questions optional; the + // composer tolerates skipped answers, so every question after + // the first stays skippable (the first carries the exchange's + // required scope, matching the mock's contract). + optional: index > 0, + })); + } + + async composeRefinedQuestion( + draft: string, + answers: Record, + ): Promise { + // The exchange answers arrive keyed by question id in insertion + // order; the backend takes the ordered answer strings. + const ordered = Object.values(answers).map((answer) => answer.trim()); + const { data, error, response } = await this.#client.POST( + "/api/refine/compose", + this.#live({ body: { draft, answers: ordered } }, true), + ); + if (error || !data) { + if (response.status === 503) { + throw new Error( + "live: refine is unavailable — the model tier resolved for your account cannot be reached", + ); + } + throw new Error("live: failed to compose the refined question"); + } + const composed = data as { question?: unknown; context?: unknown }; + return { + question: String(composed.question ?? draft), + context: String(composed.context ?? ""), + }; + } + + /* ---------------------------------------------------------------- */ + /* Profile (T-3.9, REQ-81) — the avatar display profile, live from */ + /* GET /api/profile. */ + /* ---------------------------------------------------------------- */ + + async getUserProfile(): Promise { + const { data, error } = await this.#client.GET( + "/api/profile", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load the profile"); + } + const record = data as { display_name?: unknown; email?: unknown }; + const name = String(record.display_name ?? ""); + return { + name: name !== "" ? name : "Researcher", + initials: initialsOf(name), + tagline: String(record.email ?? ""), + }; + } + + /* ---------------------------------------------------------------- */ + /* Per-user settings + model keys (P2, T-2.9, REQ-72..74) — the live */ + /* implementations, now standalone. */ + /* ---------------------------------------------------------------- */ + + /** Map the backend's UserSettings to the frontend shape (1:1). */ + #toUserSettings(payload: Record): UserSettings { + return { + planner_model: String(payload.planner_model ?? "gpt-oss:20b"), + writer_model: String(payload.writer_model ?? "gpt-oss:20b"), + methodology_defaults: + (payload.methodology_defaults as UserSettings["methodology_defaults"]) ?? {}, + appearance: String(payload.appearance ?? "system"), + }; + } + + /** Map the backend's KeyStatus — masked-only, never the raw key. */ + #toKeyStatus(payload: Record): ModelKeyStatus { + return { + configured: Boolean(payload.configured), + masked: payload.masked == null ? null : String(payload.masked), + disabled: Boolean(payload.disabled ?? false), + reason: payload.reason == null ? null : String(payload.reason), + }; + } + + async getUserSettings(): Promise { + const { data, error } = await this.#client.GET( + "/api/config/settings", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load settings"); + } + return this.#toUserSettings(data as Record); + } + + async saveUserSettings(update: Partial): Promise { + // Partial body: only the fields the caller set travel on the wire. + const body: Record = {}; + if (update.planner_model !== undefined) { + body.planner_model = update.planner_model; + } + if (update.writer_model !== undefined) { + body.writer_model = update.writer_model; + } + if (update.methodology_defaults !== undefined) { + body.methodology_defaults = update.methodology_defaults; + } + if (update.appearance !== undefined) { + body.appearance = update.appearance; + } + const { data, error, response } = await this.#client.PUT( + "/api/config/settings", + this.#live({ body }, true), + ); + if (error || !data) { + throw new Error(`live: failed to save settings (${response.status})`); + } + return this.#toUserSettings(data as Record); + } + + async setModelKey(key: string): Promise { + const { data, error, response } = await this.#client.PUT( + "/api/config/settings/model-key", + this.#live({ body: { key } }, true), + ); + if (error || !data) { + // 409: the server's key store is disabled (no KEK) — surface the + // honest reason from the backend's detail payload, never a + // silent no-op and never the raw key in any error copy. + if (response.status === 409) { + // Backend shape: {detail: {key_store, reason}} (FastAPI wraps + // the error detail); openapi-fetch hands us the parsed body. + const payload = error as unknown; + const detail = + typeof payload === "object" && + payload !== null && + "detail" in payload && + typeof (payload as { detail: unknown }).detail === "object" + ? ((payload as { detail: Record }).detail) + : (payload as Record); + const rawReason = + typeof detail === "string" + ? detail + : detail && typeof detail === "object" + ? (detail as { reason?: unknown }).reason + : undefined; + const reason = + (typeof rawReason === "string" && rawReason) || + (typeof detail === "object" && detail !== null + ? String((detail as { detail?: unknown }).detail ?? "key store disabled") + : "key store disabled"); + throw new KeyStoreDisabledError(reason); + } + throw new Error(`live: failed to store model key (${response.status})`); + } + return this.#toKeyStatus(data as Record); + } + + async getConfigStatus(): Promise { + const { data, error } = await this.#client.GET( + "/api/config", + this.#live({}, false), + ); + if (error || !data) { + throw new Error("live: failed to load config status"); + } + const backend = data as Record; + const keyStatus = backend.key_status as Record | undefined; + const counters = + (backend.cost_counters as Record | undefined) ?? {}; + return { + resolved_tier: (backend.resolved_tier as ConfigStatus["resolved_tier"]) ?? "keyless", + key_store: (backend.key_store as ConfigStatus["key_store"]) ?? "disabled", + key_status: this.#toKeyStatus(keyStatus ?? {}), + model_adapter: String(backend.model_adapter ?? "unknown"), + search_adapter: String(backend.search_adapter ?? "unknown"), + storage_dir: String(backend.storage_dir ?? ""), + probe: (backend.probe as Record | null | undefined) ?? null, + cost_counters: { + runs_created: Number(counters.runs_created ?? 0), + runs_completed: Number(counters.runs_completed ?? 0), + }, + }; + } + + /* ---------------------------------------------------------------- */ + /* Connection status (T-3.9 — the P2-verify flag closes here): the */ + /* sidebar Connections panel consumes GET /api/config mapped through */ + /* the five-slot ConnectionSlot shape, live. */ + /* ---------------------------------------------------------------- */ + + async getConnectionStatus(): Promise { + const config = await this.getConfigStatus(); + const tier = config.resolved_tier; + const modelDetail = + tier === "user" + ? `Model — your stored key (${config.model_adapter})` + : tier === "server" + ? `Model — the server's key (${config.model_adapter})` + : `Model — keyless localhost (${config.model_adapter})`; + return [ + { + slot: "model", + label: "Model", + status: tier === "keyless" ? "fallback" : "connected", + detail: modelDetail, + resolvedTier: tier, + keyStore: config.key_store, + keyStatus: config.key_status, + }, + { + slot: "search", + label: "Search", + status: "connected", + detail: `Search — ${config.search_adapter}`, + }, + { + slot: "storage", + label: "Storage", + status: "connected", + detail: `Server-managed — ${config.storage_dir}`, + }, + { + slot: "memory", + label: "Memory", + status: "preview", + detail: "Not consulted — memory scope is off in v0.3.", + }, + { + slot: "secrets", + label: "Secrets", + status: config.key_store === "enabled" ? "connected" : "fallback", + detail: + config.key_store === "enabled" + ? "Per-user key store enabled — keys encrypted at rest." + : (config.key_status.reason ?? + "Per-user key store disabled — runs resolve server/keyless."), + }, + ]; + } + + /* ---------------------------------------------------------------- */ + /* v0.1 Settings surface (REQ-65) — the prototype payload shape, */ + /* composed live from the backend reads. The P2 per-user surface */ + /* above is the live authority; here the v0.1 shape maps honestly: */ + /* masked-only key status, storage display-only (D-111), catalog- */ + /* derived defaults. */ + /* ---------------------------------------------------------------- */ + + async getSettings(): Promise { + const [config, methodologies] = await Promise.all([ + this.getConfigStatus(), + this.getMethodologies(), + ]); + const keyConfigured = config.key_status.configured; + const defaultSlug = + methodologies.find((m) => m.recommended)?.slug + ?? methodologies[0]?.slug + ?? "deep-report"; + return { + apiKeys: [ + { + id: "model", + label: "Model API key", + endpoint: "Ollama cloud (ollama.com/v1)", + maskedValue: keyConfigured ? (config.key_status.masked ?? "") : "", + configured: keyConfigured, + }, + { + id: "search", + label: "Search API key", + endpoint: "Tavily (api.tavily.com)", + maskedValue: "", + configured: false, + }, + ], + storageDirectory: config.storage_dir, + defaultMethodologySlug: defaultSlug, + defaultDials: + methodologies.find((m) => m.slug === defaultSlug)?.defaultDials + ?? methodologies[0]?.defaultDials + ?? ({} as Settings["defaultDials"]), + theme: "paper", + }; + } + + async saveSettings(update: Partial): Promise { + // In live mode the server owns every v0.1 field: the storage + // directory is display-only (D-111) and defaults resolve from the + // catalog — so a save is answered with the AUTHORITATIVE read, + // never a client-side pretend-persist. The per-user surface + // (saveUserSettings) is the live path that actually persists. + void update; + return this.getSettings(); + } } + +/* + * Compile-time contract assertion (D-112): GeneratedApiClient is a + * STANDALONE ApiClient — no MockApiClient inheritance anywhere — and + * also satisfies the v0.2 lifecycle extension. If this typechecks, + * the live binding in client.ts is safe with zero mock fallback. + */ +const _satisfies: ApiClient & RunLifecycle = new GeneratedApiClient(); +void _satisfies; \ No newline at end of file diff --git a/frontend/src/lib/api/live-surfaces.test.ts b/frontend/src/lib/api/live-surfaces.test.ts new file mode 100644 index 0000000..6a04a08 --- /dev/null +++ b/frontend/src/lib/api/live-surfaces.test.ts @@ -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): 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 { + const raw = init?.headers; + const record: Record = {}; + 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 { + 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)[method], + ).toBe("function"); + } + }); +}); \ No newline at end of file