diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json index 4776a11..5f31f2f 100644 --- a/.ciagent/CHECKPOINT.json +++ b/.ciagent/CHECKPOINT.json @@ -1,8 +1,8 @@ { - "phase": 1, - "stage": "complete", + "phase": 2, + "stage": "verify", "milestone": "v0.1", "phase_role": "execution", "attempts": 0, - "updated_at": "2026-09-27T00:55:00Z" + "updated_at": "2026-09-27T01:30:00Z" } \ No newline at end of file diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md index aa51742..4b3d918 100644 --- a/.ciagent/PLAN.md +++ b/.ciagent/PLAN.md @@ -1,80 +1,78 @@ -# PLAN — Milestone v0.1 Phase 1: Frontend Scaffold + Ask + Choose +# PLAN — Milestone v0.1 Phase 2: Plan & Run + Reading Room v0 --- -phase: 1 +phase: 2 milestone: v0.1 status: planned must_haves: - - "Next.js App Router scaffold with TypeScript strict, Tailwind, Vitest — typecheck/lint/test wired and green" - - "ApiClient interface + MockApiClient covering all 8 methodologies + scripted Deep Report run" - - "/ask and /choose routes rendering from mock data with zero API keys" + - "Plan & Run screen: plan approval UI (sub-questions, source universe, outline), scripted live trace stream, pause/cancel controls that visibly halt/restart the replay" + - "Reading Room v0: report reader with page-anchored citations (hover shows exact quoted passage), citation ledger panel with live/stale verification statuses" + - "Functional exports: real downloadable Markdown/JSON files generated from mock data; citations survive as real links" + - "Mock store upgrade: multi-run Map (P1 flag P1-3) so browser-back to earlier runs no longer 404s" personas: - frontend-engineer -requirements: [REQ-52, REQ-53, REQ-54, REQ-55, REQ-56] +requirements: [REQ-57, REQ-58, REQ-59] --- ## User-Facing Surface -The visible deliverable of this phase is the first half of the prototype click-path: -- **`/ask`** — the front door: a single question box + one optional context box, a "Research this" primary action, and nothing else competing for attention. Submitting stores question+context and routes to `/choose`. -- **`/choose`** — the methodology chooser: all 8 locked methodology cards (Rapid Answer, Deep Report, SOTA Survey, Systematic Review, Fact-Check Dossier, Multi-Perspective Dossier, Competitive Landscape, Corpus Interrogation), each showing mock ETA, cost estimate, why-recommended, and a dial summary strip; a "Recommended" badge on Deep Report; an **Advanced dials drawer** per card (progressive disclosure) exposing the 12 dials with their defaults; "Start research" routes to `/run/[id]`. - -Later phases (2–3) deliver `/run/[id]` and `/library`; phase 4 is the owner approval gate. A README quickstart (`pnpm install && pnpm dev`) is part of this phase's surface so the owner can run it locally. +- **`/run/[id]`** — the Plan & Run screen, replacing P1's placeholder. Two-stage flow mirroring the real engine: (1) **plan approval** — the run halts showing its plan (sub-questions, source universe, outline) with Approve/Edit-acknowledge actions; (2) **live trace** — on approval, scripted TraceEvents stream into a scrolling terminal-like panel (query fired → source visited → passage extracted → … → verification), with Pause/Resume and Cancel controls that visibly halt and restart the event stream. When the run reaches `done`, a link opens the Reading Room. +- **`/run/[id]/report`** — Reading Room v0: the rendered report (title, sections, per-claim citation markers), a citation ledger panel listing every source with URL, verification status (live/stale), authority tier, and passage quotes; hovering/focusing a citation marker in the body reveals the exact quoted passage (tooltip/inline popover); Export buttons download real Markdown and JSON files (citations as real links). +- README updated with the two new screens. ## Happy Path -End-to-end scenario, executable immediately after this phase: - -1. Owner runs `pnpm install && pnpm dev` (README quickstart, REQ-52) -2. Opens `http://localhost:3000` → redirected to `/ask` -3. Types "What are the latest advances in solid-state batteries?" into the question box; leaves context empty -4. Clicks "Research this" → lands on `/choose` -5. Sees 8 methodology cards; Deep Report is badged "Recommended" with ETA ~12 min, mock cost, why-recommended text -6. Opens the Advanced drawer on any card → the 12 dials with defaults are visible (progressive disclosure, REQ-56) -7. Clicks "Start research" on Deep Report → routes to `/run/[id]` (placeholder in this phase: "Plan & Run ships in phase 2") - -Three clicks from cold start to a chosen methodology with research initiated — the ≤3-click criterion (REQ-61) exercised on its first two legs. +1. Owner follows the P1 path: `/ask` → question → `/choose` → Deep Report → Start research +2. Lands on `/run/[id]` in AWAITING_APPROVAL: sees the plan (3 sub-questions, source universe, outline) and an Approve button +3. Clicks **Approve plan** — trace events begin streaming live (visible one-by-one arrival) +4. Mid-stream, clicks **Pause** — stream visibly halts (button becomes Resume); clicks **Resume** — stream continues +5. Stream completes → status DONE, link to **Read the report** +6. In Reading Room: hovers citation marker `[3]` → exact quoted passage appears with source title + verification badge +7. Clicks **Export Markdown** → real `.md` file downloads; opens it: citations are real links +8. Browser-back to an earlier run from the same session → it still renders (P1-3 fixed) ## UX Acceptance Criteria -- **Ask screen**: exactly one question box + one optional context box; no other inputs; clear primary action. Question+context survive navigation to Choose (no loss on route change). -- **Choose screen**: all 8 cards visible without scrolling failure states; each card shows ETA, cost, why-recommended, dial summary; "Recommended" badge on exactly one card; Advanced drawer opens/closes per card without layout shift (CLS-safe). -- **Progressive disclosure**: dial details hidden by default; the surface shows only what a 3-click user needs (D-018 typography tokens applied to both screens). -- **Trust affordance**: mock verification badges and confidence labels visible in card metadata where the methodology's dials demand them — the UI language of the trust loop appears from the first screen. -- **Zero keys**: the app runs with no env vars, no network calls beyond static assets. -- **Accessibility**: citation-bearing interactive elements are keyboard-reachable; focus states visible. -- **Quality gates**: `pnpm typecheck`, `pnpm lint`, `pnpm test` all green; MockApiClient fixture data drives all rendered content. +- Plan stage: sub-questions/source universe/outline clearly presented before any trace activity; Approve is the sole primary action +- Trace: events appear progressively (not all at once); each event shows type, timestamp, message; pause/cancel reachable at all times during streaming; cancel returns to a safe state (run marked cancelled, link back to Choose) +- Reading Room: report body has reading-measure (~68ch); citation markers are keyboard-focusable, hover AND focus reveal the quote; ledger lists all 6 sources with live/stale badges; verification language visible (trust affordance) +- Exports: Markdown has title, sections, and a Sources section where each citation is a real `[title](url)` link; JSON is valid (parseable) with full report + ledger structure +- All data flows through the `ApiClient` interface (`approvePlan`, `getRunEvents`, `getReport`, `getLedger`, `exportReport`) — no component imports MockApiClient +- Quality gates: `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green ## Tasks (wave-ordered; persona: frontend-engineer unless noted) -### Wave 1 — Foundation -- **T-1.1**: Scaffold Next.js App Router app in `frontend/` (TS strict, ESLint, Tailwind v4, Vitest); package `@researchx/frontend`; scripts `dev/build/lint/typecheck/test` (REQ-52) -- **T-1.2**: Design tokens: typography scale (size/leading/measure per vision §4), color, spacing as Tailwind theme extension (D-018) -- **T-1.3**: Domain types: Methodology, DialValues, RunPlan, ResearchRun, TraceEvent, Citation, CitationLedger, Task — the vocabulary of the ApiClient contract (REQ-53) -- **T-1.4**: ApiClient interface: `getMethodologies()`, `createRun()`, `getRun()`, `approvePlan()`, `getRunEvents()` (EventSource-shaped), `getReport()`, `getLedger()`, `exportReport()` (REQ-53) +### Wave 1 — Mock engine upgrade +- **T-2.1**: MockApiClient multi-run store: `Map` keyed by run id; createRun registers; getRun resolves from map (P1 flag P1-3); approvePlan persists status transitions (awaiting_approval → searching → …) (REQ-57) +- **T-2.2**: getRunEvents replay upgrade: pause/resume-aware subscription — the EventSource-shaped handle gains pause()/resume(); events stream on interval; cancel() stops and clears timers (no leaks) (REQ-57) +- **T-2.3**: Report/ledger/export fixtures: exportReport('markdown') returns full report md with Sources section (real links); exportReport('json') returns parseable JSON of report+ledger (REQ-59) -### Wave 2 — Mock data layer -- **T-2.1**: MockApiClient: fixture data for all 8 methodologies (ETA/cost/why-recommended/dial defaults per ROADMAP) (REQ-53, REQ-56) -- **T-2.2**: Scripted Deep Report run: plan → trace events → report with ~6 sources, page-anchored citations with exact quotes, verification statuses (live/stale), confidence labels (REQ-53, D-012) -- **T-2.3**: Vitest coverage: fixtures typecheck; MockApiClient satisfies ApiClient (REQ-53) +### Wave 2 — Plan & Run screen +- **T-2.4**: `/run/[id]` plan approval stage: sub-questions list, source universe chips, outline; Approve button → api.approvePlan → transitions to trace stage (REQ-57) +- **T-2.5**: Trace stream UI: progressive event rendering (query/source/passage/synthesis/verification types with distinct visual treatment), auto-scroll, Pause/Resume + Cancel controls wired to subscription (REQ-57) +- **T-2.6**: Run completion → DONE state with link to `/run/[id]/report` (REQ-57) -### Wave 3 — Screens -- **T-3.1**: `/ask` — question + optional context, "Research this" → `/choose` with state carried (REQ-55) -- **T-3.2**: `/choose` — 8 cards, Recommended badge, dial summary strips, Advanced drawer (12 dials, defaults), "Start research" (REQ-56) -- **T-3.3**: Component tests: Ask state carry, Choose renders 8 cards from MockApiClient, drawer disclosure (REQ-55, REQ-56) -- **T-3.4**: Root redirect → `/ask`; README quickstart section (REQ-52) +### Wave 3 — Reading Room v0 +- **T-2.7**: Report reader: title, sections, citation markers rendered from Report data; reading-measure typography (REQ-58) +- **T-2.8**: Citation interaction: hover/focus on marker → popover with exact quote, source title, verification badge (live/stale), authority tier; ledger panel listing all sources (REQ-58) +- **T-2.9**: Export buttons: download Markdown + JSON via exportReport; real files, citations as links (REQ-59) +- **T-2.10**: README update: new screens section (REQ-57..59) -### Wave 4 — Gate (persona: ci-lead-developer) -- **T-4.1**: Boundary review: no component imports MockApiClient directly (REQ-54); typecheck/lint/test green; commit discipline verified +### Wave 4 — Tests (persona: frontend-engineer) +- **T-2.11**: Plan approval flow test (approve → status change → trace begins); pause/resume test; cancel test (REQ-57) +- **T-2.12**: Reading Room tests: markers render, popover shows quote on hover/focus, ledger lists sources; export content tests (markdown contains links, JSON parses) (REQ-58, REQ-59) +- **T-2.13**: Mock store regression: two runs created, both retrievable (P1-3 closure) (REQ-57) + +### Wave 5 — Gate (persona: ci-lead-developer) +- **T-2.14**: Boundary review: no component imports mock layer; timer cleanup verified (no dangling intervals); typecheck/lint/test/build green ## Verification -- `pnpm typecheck && pnpm lint && pnpm test` green -- Manual happy-path walk (above) passes its 7 steps -- Requirement coverage: REQ-52 ✓, REQ-53 ✓, REQ-54 ✓ (boundary review), REQ-55 ✓, REQ-56 ✓ +- `pnpm typecheck && pnpm lint && pnpm test && pnpm build` green +- Happy-path walk (8 steps above) passes +- Requirement coverage: REQ-57 ✓, REQ-58 ✓, REQ-59 ✓ +- P1 flags closed: P1-3 (multi-run store) closed by T-2.1/T-2.13; P1-4 partially (run-view now exercised by T-2.11..13) -## Delegation (execute workflow) +## Scope Guard -Phase 1 executes via the frontend-engineer persona subagent (waves 1–3), ci-lead-developer for wave 4 boundary review. Task commits use `feat(P01-NN): …` with `---ci---` blocks carrying `phase: 1`, `milestone: v0.1`, `persona`, `task`, `requirements.covered`. Ship delegates to the ship workflow (tag v0.1.1, PR phase/01 → milestone/v0.1-prototype). - -**Scope guard**: this plan covers v0.1 P1 only. `/run/[id]`, `/library`, exports, v2 mockups are P2/P3 plans written at their phase boundaries (P0 of this checkpoint does not plan them in detail). No MVP (v0.2) work is planned or executed before v0.1.4 + owner approval. \ No newline at end of file +This plan covers v0.1 P2 only. v2 mockups (project graph, task inbox), typography pass, 3-click validation are P3 (REQ-60, REQ-61). P1-flags P1-1/P1-2 (drawer hyphen rendering, empty-question guard) remain routed to P4 pre-demo fixes. No MVP (v0.2) work. \ No newline at end of file diff --git a/frontend/README.md b/frontend/README.md index 42cf242..3ec3f6c 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -22,8 +22,14 @@ The happy path: 1. Type a question (context is optional) → **Research this** 2. Pick a methodology on `/choose` — Deep Report is the recommended default; open **Advanced** on any card to see its 12 dials -3. **Start research** → `/run/[id]` (a placeholder in this phase: the - Plan & Run screen ships in phase 2) +3. **Start research** → `/run/[id]`: review the plan (sub-questions, + source universe, outline), **Approve plan**, and watch the scripted + live trace stream in — **Pause/Resume** or **Cancel** at any time +4. When the run completes → **Read the report** → `/run/[id]/report`: + the Reading Room renders the report with page-anchored citations — + hover or focus any `[n]` marker to see the exact quoted passage — + plus the citation ledger and **Export Markdown/JSON** buttons that + download real files with citations as real links ## Scripts @@ -55,12 +61,15 @@ The happy path: ``` src/ - app/ App Router routes (ask, choose, run/[id]) + app/ App Router routes (ask, choose, run/[id], run/[id]/report) components/ UI components (server by default, client where interactive) lib/ Domain types, ApiClient boundary, mock data, stores test/ Test harness setup ``` Prototype scope note: ETAs, cost estimates, and the scripted run are -realistic fixture data — not live research. The Reading Room, live -trace, and exports ship in later phases of the milestone. \ No newline at end of file +realistic fixture data — not live research. The trace stream replays a +deterministic Deep Report run with pause/resume/cancel controls; the +Reading Room's report, ledger, and exports are generated from the same +fixture. The v2 surface mockups (project graph, task inbox) ship in the +next phase. \ No newline at end of file diff --git a/frontend/src/app/run/[id]/report/page.tsx b/frontend/src/app/run/[id]/report/page.tsx new file mode 100644 index 0000000..5795804 --- /dev/null +++ b/frontend/src/app/run/[id]/report/page.tsx @@ -0,0 +1,14 @@ +import { ReadingRoom } from "@/components/reading-room"; + +export default async function ReportPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/reading-room.test.tsx b/frontend/src/components/reading-room.test.tsx new file mode 100644 index 0000000..aa942b4 --- /dev/null +++ b/frontend/src/components/reading-room.test.tsx @@ -0,0 +1,109 @@ +/** + * Reading Room tests (T-2.12, REQ-58, REQ-59). + * + * - Report renders title + sections with citation markers + * - Marker focus reveals the exact quoted passage (trust affordance) + * - Ledger panel lists all sources with health badges + * - Exports produce real content: markdown carries [title](url) links; + * JSON parses + */ + +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ReadingRoom } from "@/components/reading-room"; +import { api } from "@/lib/api/client"; +import { parseReportBody } from "@/lib/report-body"; + +const QUESTION = "What are the latest advances in solid-state batteries?"; + +async function runId(): Promise { + const run = await api.createRun({ + question: QUESTION, + methodologySlug: "deep-report", + }); + return run.id; +} + +describe("parseReportBody", () => { + it("splits bodies into text and citation segments", () => { + const segments = parseReportBody("Density gains [^p1] and cycling [^p2]."); + expect(segments).toEqual([ + { kind: "text", text: "Density gains " }, + { kind: "citation", passageId: "p1" }, + { kind: "text", text: " and cycling " }, + { kind: "citation", passageId: "p2" }, + { kind: "text", text: "." }, + ]); + }); + + it("leaves marker-free bodies as a single text segment", () => { + expect(parseReportBody("Plain body.")).toEqual([ + { kind: "text", text: "Plain body." }, + ]); + }); +}); + +describe("ReadingRoom", () => { + afterEach(cleanup); + + it("renders the report with citation markers and the ledger", async () => { + const id = await runId(); + render(); + + expect( + await screen.findByText(`Deep Report — ${QUESTION}`), + ).toBeInTheDocument(); + // Section headings render. + expect(screen.getByText("Executive summary")).toBeInTheDocument(); + // Citation markers render as superscript buttons (markers repeat + // across sections — assert presence, not uniqueness). + expect(screen.getAllByRole("button", { name: /Citation 1:/i }).length).toBeGreaterThan(0); + expect(screen.getAllByRole("button", { name: /Citation 2:/i }).length).toBeGreaterThan(0); + + // Ledger panel lists all 6 sources with verification language. + expect(screen.getByText("Citation ledger")).toBeInTheDocument(); + expect( + screen.getByText("6 sources · every claim traceable to a visited passage"), + ).toBeInTheDocument(); + expect( + screen.getByText("Sulfide Electrolytes for All-Solid-State Lithium Batteries: A Review"), + ).toBeInTheDocument(); + expect(screen.getAllByText(/live/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText(/stale/i)).toBeInTheDocument(); + }); + + it("reveals the exact quoted passage on marker focus", async () => { + const id = await runId(); + render(); + + const markers = await screen.findAllByRole("button", { name: /Citation 2:/i }); + const marker = markers[0]; + if (marker === undefined) throw new Error("citation 2 marker not rendered"); + marker.focus(); + const tooltip = await screen.findByRole("tooltip"); + // The exact quote from passage p2 — the hover-to-quote affordance. + expect(tooltip.textContent).toContain( + "Sulfide electrolytes exhibit ionic conductivity comparable to liquid electrolytes", + ); + expect(tooltip.textContent).toContain("arxiv.org"); + expect(tooltip.textContent).toContain("tier 1"); + }); + + it("export content: markdown carries real links; JSON parses (REQ-59)", async () => { + const id = await runId(); + const markdown = await api.exportReport(id, "markdown"); + expect(markdown.filename).toBe(`researchx-${id}.md`); + expect(markdown.content).toContain("## Sources"); + expect(markdown.content).toMatch(/\[.*\]\(https:\/\/.+\)/); + expect(markdown.content).toContain("](https://arxiv.org/abs/2401.00001)"); + + const json = await api.exportReport(id, "json"); + expect(json.filename).toBe(`researchx-${id}.json`); + const parsed = JSON.parse(json.content) as { + report: { runId: string }; + ledger: { citations: unknown[] }; + }; + expect(parsed.report.runId).toBe(id); + expect(parsed.ledger.citations).toHaveLength(6); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/reading-room.tsx b/frontend/src/components/reading-room.tsx new file mode 100644 index 0000000..a1d02c9 --- /dev/null +++ b/frontend/src/components/reading-room.tsx @@ -0,0 +1,250 @@ +"use client"; + +/** + * ReadingRoom (T-2.7..T-2.9, REQ-58, REQ-59) — the /run/[id]/report screen. + * + * Report reader with page-anchored citations: [^pN] markers render as + * superscript links; hover OR keyboard focus reveals the exact quoted + * passage with source title, verification badge, and authority tier. + * The citation ledger panel lists every consulted source. Export + * buttons download real Markdown/JSON files with citations as links. + */ + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { api, type Citation, type Report, type CitationLedger } from "@/lib/api/client"; +import { parseReportBody } from "@/lib/report-body"; + +type RoomState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "ready"; report: Report; ledger: CitationLedger }; + +export function ReadingRoom({ id }: { id: string }) { + const [state, setState] = useState({ kind: "loading" }); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [report, ledger] = await Promise.all([ + api.getReport(id), + api.getLedger(id), + ]); + if (!cancelled) setState({ kind: "ready", report, ledger }); + } catch (cause) { + if (!cancelled) { + setState({ + kind: "error", + message: cause instanceof Error ? cause.message : "Report not found", + }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [id]); + + if (state.kind === "loading") { + return

Loading report…

; + } + if (state.kind === "error") { + return ( +
+

+ {state.message} +

+

+ + Ask a question + +

+
+ ); + } + + const { report, ledger } = state; + const byPassage = new Map(report.citations.map((c) => [c.passageId, c])); + + return ( +
+

+ + ← Back to run + +

+

+ {report.title} +

+ +
+ {/* Report body */} +
+ {report.sections.map((section) => ( +
+

{section.heading}

+
+ {parseReportBody(section.body).map((segment, i) => + segment.kind === "text" ? ( + {segment.text} + ) : ( + + ), + )} +
+
+ ))} + + +
+ + {/* Citation ledger panel */} + +
+
+ ); +} + +function HealthBadge({ health }: { health: Citation["verification"] }) { + const styles = + health === "live" + ? "bg-success/10 text-success" + : health === "stale" + ? "bg-warn/10 text-warn" + : "bg-danger/10 text-danger"; + const label = health === "live" ? "✓ live" : health === "stale" ? "△ stale" : "✗ dead"; + return ( + + {label} + + ); +} + +/** + * A page-anchored citation marker. Hover or keyboard focus reveals the + * exact quoted passage — the trust affordance of the Reading Room. + */ +function CitationMarker({ + citation, + passageId, +}: { + citation: Citation | undefined; + passageId: string; +}) { + const [open, setOpen] = useState(false); + + if (citation === undefined) { + // Unknown passage id — render inert text rather than a broken link. + return [{passageId}]; + } + + const markerIndex = citation.passageId.replace(/^p/, ""); + + return ( + + + {open && ( + + {citation.title} + + {citation.site} · tier {citation.authorityTier} · + + “{citation.quote}” + + )} + + ); +} + +/** Download buttons for real Markdown/JSON exports (REQ-59). */ +function ExportButtons({ runId }: { runId: string }) { + const [busy, setBusy] = useState<"markdown" | "json" | null>(null); + + const download = async (format: "markdown" | "json") => { + setBusy(format); + try { + const { filename, content } = await api.exportReport(runId, format); + const blob = new Blob([content], { + type: format === "markdown" ? "text/markdown" : "application/json", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); + } finally { + setBusy(null); + } + }; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/run-view.test.tsx b/frontend/src/components/run-view.test.tsx new file mode 100644 index 0000000..d7c6a6a --- /dev/null +++ b/frontend/src/components/run-view.test.tsx @@ -0,0 +1,124 @@ +/** + * Plan & Run screen tests (T-2.11, REQ-57). + * + * - Plan approval stage renders sub-questions/universe/outline + Approve + * - Approve → status change → trace events stream in progressively + * - Pause halts event arrival; Resume continues + * - Cancel → cancelled state with link back to /choose + * - Replay completion → done state with link to the Reading Room + */ + +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RunView } from "@/components/run-view"; +import { api } from "@/lib/api/client"; + +const pushMock = vi.hoisted(() => vi.fn()); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: pushMock }), +})); + +const QUESTION = "What are the latest advances in solid-state batteries?"; + +async function makeRun() { + return api.createRun({ question: QUESTION, methodologySlug: "deep-report" }); +} + +describe("RunView — Plan & Run", () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + pushMock.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + cleanup(); + }); + + it("shows the plan with sub-questions, source universe, and outline before approval", async () => { + const run = await makeRun(); + render(); + + expect( + await screen.findByText("Research plan — approval required"), + ).toBeInTheDocument(); + expect( + screen.getByText("What is the current state of solid-state battery technology?"), + ).toBeInTheDocument(); + expect( + screen.getByText("open web (technology and industry press)"), + ).toBeInTheDocument(); + expect(screen.getByText("Executive summary")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /Approve plan & start research/i }), + ).toBeEnabled(); + }); + + it("approve streams trace events progressively and reaches done", async () => { + const run = await makeRun(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + const approve = await screen.findByRole("button", { + name: /Approve plan & start research/i, + }); + await user.click(approve); + + // First trace event arrives (progressive — not all at once). + expect(await screen.findByText(/Plan drafted/i)).toBeInTheDocument(); + // The plan stage is replaced by the streaming stage. + expect( + screen.queryByText("Research plan — approval required"), + ).not.toBeInTheDocument(); + + // Let the whole scripted trace replay through fake time. + await vi.advanceTimersByTimeAsync(23 * 150 + 100); + + // Done stage: report link points at the Reading Room route. + const reportLink = await screen.findByRole("link", { name: /Read the report/i }); + expect(reportLink).toHaveAttribute("href", `/run/${run.id}/report`); + expect((await api.getRun(run.id)).status).toBe("done"); + }); + + it("pause halts the stream; resume continues it", async () => { + const run = await makeRun(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.click( + await screen.findByRole("button", { name: /Approve plan & start research/i }), + ); + await vi.advanceTimersByTimeAsync(3 * 150); + const eventsAtPause = screen.getAllByRole("listitem").length; + + await user.click(screen.getByRole("button", { name: /⏸ Pause/i })); + await vi.advanceTimersByTimeAsync(5 * 150); + // Paused: no new events rendered. + expect(screen.getAllByRole("listitem")).toHaveLength(eventsAtPause); + + await user.click(screen.getByRole("button", { name: /▶ Resume/i })); + await vi.advanceTimersByTimeAsync(5 * 150); + // Resumed: events continue arriving. + expect(screen.getAllByRole("listitem").length).toBeGreaterThan(eventsAtPause); + }); + + it("cancel stops the run and links back to /choose", async () => { + const run = await makeRun(); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.click( + await screen.findByRole("button", { name: /Approve plan & start research/i }), + ); + await vi.advanceTimersByTimeAsync(2 * 150); + + await user.click(screen.getByRole("button", { name: /Cancel/i })); + expect(await screen.findByText("Run cancelled")).toBeInTheDocument(); + const back = screen.getByRole("link", { + name: /Choose a methodology to start over/i, + }); + expect(back).toHaveAttribute("href", "/choose"); + expect((await api.getRun(run.id)).status).toBe("cancelled"); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/run-view.tsx b/frontend/src/components/run-view.tsx index e7bab9a..91a07c3 100644 --- a/frontend/src/components/run-view.tsx +++ b/frontend/src/components/run-view.tsx @@ -1,66 +1,173 @@ "use client"; /** - * RunView (T-3.2b, client half) — the /run/[id] placeholder body. + * RunView (T-2.4..T-2.6, REQ-57) — the /run/[id] Plan & Run screen. * - * Reads the run through the ApiClient on the CLIENT because the mock - * binding keeps its scripted-run state in browser module state - * (createRun was called there). Phase 2 replaces this with the real - * server-rendered Plan & Run screen backed by GET /api/runs/{id}. + * Three stages mirroring the real engine: + * 1. awaiting_approval — the plan (sub-questions, source universe, + * outline) with Approve as the sole primary action + * 2. streaming — the scripted live trace with Pause/Resume + Cancel + * 3. done/cancelled — terminal states with onward links + * + * All data flows through the ApiClient singleton; the mock binding + * keeps run state in browser module state, so this is a client + * component (same pattern as P1). */ import Link from "next/link"; -import { useEffect, useState } from "react"; -import { api } from "@/lib/api/client"; -import type { Methodology, ResearchRun } from "@/lib/types"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { api, type RunEventSubscription } from "@/lib/api/client"; +import type { Methodology, ResearchRun, TraceEvent } from "@/lib/types"; -type RunState = - | { phase: "loading" } - | { phase: "ready"; run: ResearchRun; methodology: Methodology | undefined } - | { phase: "error"; message: string }; +type Stage = + | { kind: "loading" } + | { kind: "error"; message: string } + | { + kind: "plan"; + run: ResearchRun; + methodology: Methodology | undefined; + approving: boolean; + } + | { + kind: "stream"; + run: ResearchRun; + methodology: Methodology | undefined; + events: TraceEvent[]; + paused: boolean; + } + | { kind: "done"; run: ResearchRun } + | { kind: "cancelled" }; + +const EVENT_GLYPHS: Record = { + query: "?", + source_visited: "◆", + passage_extracted: "✂", + synthesis: "✍", + verification: "✓", + plan: "▤", + status: "▸", +}; + +/** HH:MM:SS in the fixture's UTC timeline — the trace clock. */ +function formatTime(ts: string): string { + return new Date(ts).toISOString().slice(11, 19); +} export function RunView({ id }: { id: string }) { - const [state, setState] = useState({ phase: "loading" }); + const [stage, setStage] = useState({ kind: "loading" }); + const subscriptionRef = useRef(null); + const listRef = useRef(null); + // Initial load: run + methodology for the card header. useEffect(() => { let cancelled = false; - async function load() { + (async () => { try { - const [run, methodologies] = await Promise.all([ - api.getRun(id), - api.getMethodologies(), - ]); + const run = await api.getRun(id); if (cancelled) return; - setState({ - phase: "ready", - run, - methodology: methodologies.find( - (m) => m.slug === run.plan.methodologySlug, - ), - }); + if (run.status === "cancelled") { + setStage({ kind: "cancelled" }); + return; + } + const methodology = (await api.getMethodologies()).find( + (m) => m.slug === run.plan.methodologySlug, + ); + if (cancelled) return; + if (run.status === "awaiting_approval" || run.status === "planning") { + setStage({ kind: "plan", run, methodology, approving: false }); + } else if (run.status === "done") { + setStage({ kind: "done", run }); + } else { + // Mid-stream reload: show plan stage; Approve resumes the flow. + setStage({ kind: "plan", run: { ...run, status: "awaiting_approval" }, methodology, approving: false }); + } } catch (cause) { - if (cancelled) return; - setState({ - phase: "error", - message: cause instanceof Error ? cause.message : "Run not found", - }); + if (!cancelled) { + setStage({ + kind: "error", + message: cause instanceof Error ? cause.message : "Run not found", + }); + } } - } - void load(); + })(); return () => { cancelled = true; + subscriptionRef.current?.cancel(); + subscriptionRef.current = null; }; }, [id]); - if (state.phase === "loading") { + // Auto-scroll the trace container to the newest event while streaming. + // The container itself is the scroller — scrollTop, not scrollIntoView. + // (scrollIntoView would move ancestors, not the inner fold.) + useEffect(() => { + if (stage.kind === "stream" && !stage.paused) { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + } + }, [stage]); + + const beginStream = useCallback( + (run: ResearchRun, methodology: Methodology | undefined) => { + setStage({ kind: "stream", run, methodology, events: [], paused: false }); + const subscription = api.getRunEvents( + run.id, + (event) => { + setStage((s) => + s.kind === "stream" ? { ...s, events: [...s.events, event] } : s, + ); + }, + () => { + // DONE arrives via the stream terminal event (engine-owned + // in v0.2); locally the mock has already persisted it. + setStage({ kind: "done", run }); + }, + ); + subscriptionRef.current = subscription; + }, + [], + ); + + const approve = useCallback( + async (run: ResearchRun, methodology: Methodology | undefined) => { + setStage((s) => (s.kind === "plan" ? { ...s, approving: true } : s)); + try { + const approved = await api.approvePlan(run.id); + beginStream(approved, methodology); + } catch { + setStage((s) => + s.kind === "plan" ? { ...s, approving: false } : s, + ); + } + }, + [beginStream], + ); + + const togglePause = useCallback(() => { + setStage((s) => { + if (s.kind !== "stream") return s; + if (s.paused) subscriptionRef.current?.resume(); + else subscriptionRef.current?.pause(); + return { ...s, paused: !s.paused }; + }); + }, []); + + const cancel = useCallback(async () => { + subscriptionRef.current?.cancel(); + subscriptionRef.current = null; + await api.cancelRun(id).catch(() => undefined); + setStage({ kind: "cancelled" }); + }, [id]); + + if (stage.kind === "loading") { return

Loading run…

; } - if (state.phase === "error") { + if (stage.kind === "error") { return (

- {state.message} + {stage.message}

@@ -71,7 +178,49 @@ export function RunView({ id }: { id: string }) { ); } - const { run, methodology } = state; + if (stage.kind === "cancelled") { + return ( +

+

+ Run cancelled +

+

+ The research run was stopped. No report was written. +

+

+ + Choose a methodology to start over + +

+
+ ); + } + + if (stage.kind === "done") { + return ( +
+

+ ✓ Run complete +

+

+ {stage.run.plan.question} +

+

+ The report and citation ledger are ready in the Reading Room. +

+

+ + Read the report + +

+
+ ); + } + + const { run, methodology } = stage; return (
@@ -92,25 +241,117 @@ export function RunView({ id }: { id: string }) { )}

-
-

- Plan & Run ships in phase 2 -

-

- In the prototype, the live trace stream, plan approval, and - pause/cancel controls are the next milestone phase. Your run id is{" "} - - {run.id} - - . -

-
+ {stage.kind === "plan" ? ( +
+
+

+ Research plan — approval required +

+

+ Sub-questions +

+
    + {run.plan.subQuestions.map((q) => ( +
  1. {q}
  2. + ))} +
+

+ Source universe +

+
    + {run.plan.sourceUniverse.map((s) => ( +
  • + {s} +
  • + ))} +
+

Outline

+
    + {run.plan.outline.map((o) => ( +
  1. {o}
  2. + ))} +
+
+

+ +

+
+ ) : null} -

- - Ask another question - -

+ {stage.kind === "stream" ? ( +
+
+ + + + {stage.events.length} events{stage.paused ? " · paused" : ""} + +
+ +
+ {stage.events.length === 0 ? ( +

+ Research starting — first events arriving… +

+ ) : ( +
    + {stage.events.map((event) => ( +
  • + + {EVENT_GLYPHS[event.type]} + + + {event.message} + {event.detail !== undefined && ( + + {event.detail} + + )} + + +
  • + ))} +
+ )} +
+
+ ) : null}
); } \ No newline at end of file diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 9976e4e..816dfaf 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -38,9 +38,17 @@ export interface CreateRunInput { dialOverrides?: Partial; } -/** Subscription handle for the live trace stream (EventSource-shaped). */ +/** + * Subscription handle for the live trace stream (EventSource-shaped). + * v0.2 backs this with SSE; pause/resume stay client-side concerns. + */ export interface RunEventSubscription { + /** Stop the stream and release its timers (idempotent). */ cancel(): void; + /** Halt event delivery — the stream buffers until resumed. */ + pause(): void; + /** Continue delivering buffered + incoming events. */ + resume(): void; } /** @@ -60,6 +68,9 @@ export interface ApiClient { /** Approve the plan; the run proceeds past AWAITING_APPROVAL. */ approvePlan(id: string): Promise; + /** Cancel a streaming run — terminal state (maps to POST /api/runs/{id}/cancel). */ + cancelRun(id: string): Promise; + /** * Stream a run's trace events to `onEvent` as they replay/arrive, * then invoke `onDone`. Returns a handle that cancels the stream. diff --git a/frontend/src/lib/api/mock/mock-client.test.ts b/frontend/src/lib/api/mock/mock-client.test.ts index d2f9dc2..63406f9 100644 --- a/frontend/src/lib/api/mock/mock-client.test.ts +++ b/frontend/src/lib/api/mock/mock-client.test.ts @@ -138,8 +138,15 @@ describe("MockApiClient (bound as `api`)", () => { const run = await client.createRun({ question, methodologySlug: "deep-report" }); const json = await client.exportReport(run.id, "json"); expect(json.filename).toMatch(/\.json$/); - const parsed = JSON.parse(json.content) as { runId: string }; - expect(parsed.runId).toBe(run.id); + // P2 shape: { report, ledger } — the export carries the full audit record. + const parsed = JSON.parse(json.content) as { + report: { runId: string; citations: unknown[] }; + ledger: { runId: string; citations: unknown[] }; + }; + expect(parsed.report.runId).toBe(run.id); + expect(parsed.ledger.runId).toBe(run.id); + expect(parsed.report.citations).toHaveLength(6); + expect(parsed.ledger.citations).toHaveLength(6); }); it("getLedger returns 6 citations with the run id and health mix", async () => { @@ -171,4 +178,95 @@ describe("MockApiClient (bound as `api`)", () => { expect(seen.length).toBeGreaterThanOrEqual(12); expect(seen[0]).toContain("Plan drafted"); }); + + /* ---------------------------------------------------------------- */ + /* P2: multi-run store, approval persistence, pause/resume (T-2.13) */ + /* ---------------------------------------------------------------- */ + + it("keeps every created run retrievable — back-nav does not 404 (P1-3)", async () => { + const runA = await client.createRun({ + question: "What are the latest advances in solid-state batteries?", + methodologySlug: "deep-report", + }); + const runB = await client.createRun({ + question: "How do vector databases handle billion-scale embeddings?", + methodologySlug: "deep-report", + }); + expect(runA.id).not.toBe(runB.id); + const fetchedA = await client.getRun(runA.id); + const fetchedB = await client.getRun(runB.id); + expect(fetchedA.plan.question).toContain("solid-state"); + expect(fetchedB.plan.question).toContain("vector databases"); + }); + + it("createRun halts at awaiting_approval; approvePlan persists the transition", async () => { + const run = await client.createRun({ + question: "What are the latest advances in solid-state batteries?", + methodologySlug: "deep-report", + }); + expect(run.status).toBe("awaiting_approval"); + const approved = await client.approvePlan(run.id); + expect(approved.status).toBe("searching"); + // The transition PERSISTS — a getRun afterwards must see it (P1 flag). + const refetched = await client.getRun(run.id); + expect(refetched.status).toBe("searching"); + }); + + it("cancelRun persists cancelled; completeRun persists done (mock-local)", async () => { + const run = await client.createRun({ + question: "What are the latest advances in solid-state batteries?", + methodologySlug: "deep-report", + }); + const cancelled = await client.cancelRun(run.id); + expect(cancelled.status).toBe("cancelled"); + expect((await client.getRun(run.id)).status).toBe("cancelled"); + + const run2 = await client.createRun({ + question: "A second question about solid-state batteries?", + methodologySlug: "deep-report", + }); + // completeRun is mock-local (not on ApiClient) — the typed binding + // exposes it via the concrete class; the cast is the test seam. + const mock = client as unknown as { completeRun(id: string): Promise<{ status: string }> }; + const done = await mock.completeRun(run2.id); + expect(done.status).toBe("done"); + expect((await client.getRun(run2.id)).status).toBe("done"); + }); + + it("pause halts event delivery; resume continues; cancel stops for good", async () => { + const run = await client.createRun({ + question: "What are the latest advances in solid-state batteries?", + methodologySlug: "deep-report", + }); + let count = 0; + let finished = false; + const subscription = client.getRunEvents( + run.id, + () => { + count += 1; + }, + () => { + finished = true; + }, + ); + + // Let a few events arrive, then pause. + await new Promise((r) => setTimeout(r, 500)); + subscription.pause(); + const countAtPause = count; + // While paused, no further events arrive. + await new Promise((r) => setTimeout(r, 450)); + expect(count).toBe(countAtPause); + + subscription.resume(); + await new Promise((r) => setTimeout(r, 450)); + expect(count).toBeGreaterThan(countAtPause); + + // Cancel stops delivery permanently and never fires onDone. + subscription.cancel(); + const countAtCancel = count; + await new Promise((r) => setTimeout(r, 400)); + expect(count).toBe(countAtCancel); + expect(finished).toBe(false); + }); }); \ No newline at end of file diff --git a/frontend/src/lib/api/mock/mock-client.ts b/frontend/src/lib/api/mock/mock-client.ts index 23c4eb2..139aca2 100644 --- a/frontend/src/lib/api/mock/mock-client.ts +++ b/frontend/src/lib/api/mock/mock-client.ts @@ -22,17 +22,37 @@ import type { TraceEvent, } from "@/lib/types"; -/** Handle returned by getRunEvents — EventSource-shaped cancel(). */ +/** Handle returned by getRunEvents — EventSource-shaped, pause-aware. */ export interface RunEventSubscription { cancel(): void; + pause(): void; + resume(): void; } /** Interval between replayed trace events (ms). */ const EVENT_REPLAY_INTERVAL_MS = 150; export class MockApiClient implements ApiClient { - /** The most recent run created in this session (getRun/getReport/… key off it). */ - #lastRun: ResearchRun | undefined; + /** + * Session store of created runs, keyed by id — back-navigation to an + * earlier run resolves instead of 404ing (P1 review flag P1-3). + */ + #runs = new Map(); + + /** Cancel the run from the trace UI — terminal state, persisted. */ + async cancelRun(id: string): Promise { + return this.#setStatus(id, "cancelled"); + } + + /** + * Mark the run done once its trace replay completes. + * Mock-local (NOT on the ApiClient): in v0.2 the engine owns the + * DONE transition and it arrives via the SSE stream — no separate + * client call exists, so the interface stays honest. + */ + async completeRun(id: string): Promise { + return this.#setStatus(id, "done"); + } async getMethodologies(): Promise { return [...methodologyFixtures]; @@ -47,30 +67,51 @@ export class MockApiClient implements ApiClient { } // dialOverrides are accepted and recorded per REQ-56's drawer contract; // the scripted run's behavior does not branch on them in the prototype. + // The run halts at AWAITING_APPROVAL — the Plan & Run screen drives it + // forward via approvePlan (REQ-57's approval gate). const run = buildScriptedRun({ question: input.question, ...(input.context !== undefined ? { context: input.context } : {}), methodologySlug: methodology.slug, }); - this.#lastRun = run; - return run; + const runAwaitingApproval: ResearchRun = { ...run, status: "awaiting_approval" }; + this.#runs.set(run.id, runAwaitingApproval); + return structuredClone(runAwaitingApproval); } async getRun(id: string): Promise { - // A run id that matches the scripted question hash, or the most - // recent created run — either resolves to the same scripted data. - if (this.#lastRun?.id === id || this.#lastRun === undefined) { - return structuredClone(this.#lastRun ?? this.#defaultRun()); + const stored = this.#runs.get(id); + if (stored) { + return structuredClone(stored); } - if (id === this.#defaultRun().id) { - return structuredClone(this.#defaultRun()); - } - throw new Error(`MockApiClient: run "${id}" not found`); + // No created run this session — fall back to the scripted demo run so + // deep links and hard refreshes keep working (prototype behavior). + return structuredClone(this.#defaultRun()); } async approvePlan(id: string): Promise { const run = await this.getRun(id); - return { ...run, status: "searching" }; + if (run.status !== "awaiting_approval") { + return run; + } + const approved: ResearchRun = { ...run, status: "searching" }; + this.#runs.set(id, approved); + return structuredClone(approved); + } + + /** Persist a terminal (or transitional) status from the trace UI. */ + async #setStatus(id: string, status: ResearchRun["status"]): Promise { + const run = await this.getRun(id); + const updated: ResearchRun = { ...run, status }; + const known = this.#runs.has(id) || this.#isDefaultRun(id); + if (known) { + this.#runs.set(id, updated); + } + return structuredClone(updated); + } + + #isDefaultRun(id: string): boolean { + return id === this.#defaultRun().id; } getRunEvents( @@ -81,21 +122,47 @@ export class MockApiClient implements ApiClient { const events = scriptedTraceEvents(id); let index = 0; let cancelled = false; + let paused = false; // Replay the scripted trace one event at a time, then signal done. - const timer = setInterval(() => { - if (cancelled) return; - if (index >= events.length) { + // Pause halts delivery by re-arming the timer without emitting. + let timer: ReturnType | undefined; + const arm = () => { + timer = setInterval(() => { + if (cancelled) return; + if (paused) return; + if (index >= events.length) { + finish(); + return; + } + onEvent(events[index] as TraceEvent); + index += 1; + }, EVENT_REPLAY_INTERVAL_MS); + }; + const stopTimer = () => { + if (timer !== undefined) { clearInterval(timer); - onDone?.(); - return; + timer = undefined; } - onEvent(events[index] as TraceEvent); - index += 1; - }, EVENT_REPLAY_INTERVAL_MS); + }; + const finish = () => { + stopTimer(); + // Engine-owned DONE transition: persist, then notify subscriber. + void this.completeRun(id).then(() => onDone?.()); + }; + arm(); + // When the scripted trace finishes, transition the stored run to + // DONE (the engine owns this in v0.2; the mock owns it here) and + // deliver the terminal onDone to the subscriber. return { cancel(): void { cancelled = true; - clearInterval(timer); + stopTimer(); + }, + pause(): void { + paused = true; + }, + resume(): void { + paused = false; }, }; } @@ -116,14 +183,15 @@ export class MockApiClient implements ApiClient { ): Promise<{ filename: string; content: string }> { const run = await this.getRun(id); const report = buildScriptedReport(run, run.plan.question); + const ledger = buildScriptedLedger(run); if (format === "json") { return { - filename: `${report.runId}-report.json`, - content: JSON.stringify(report, null, 2), + filename: `researchx-${report.runId}.json`, + content: JSON.stringify({ report, ledger }, null, 2), }; } return { - filename: `${report.runId}-report.md`, + filename: `researchx-${report.runId}.md`, content: renderReportMarkdown(report), }; } diff --git a/frontend/src/lib/report-body.ts b/frontend/src/lib/report-body.ts new file mode 100644 index 0000000..a439a3b --- /dev/null +++ b/frontend/src/lib/report-body.ts @@ -0,0 +1,41 @@ +/** + * Citation-marker parsing (T-2.8, REQ-58). + * + * Splits a report section body on [^pN] citation markers into plain + * text segments and marker segments. NO HTML string building — the + * Reading Room maps these to React nodes, so nothing is ever + * injected; all text renders as escaped React text nodes. + */ + +export interface MarkerRef { + passageId: string; +} + +export type ReportBodySegment = + | { kind: "text"; text: string } + | { kind: "citation"; passageId: string }; + +const MARKER_RE = /\[\^p([A-Za-z0-9]+)\]/g; + +/** + * Parse a section body into text/citation segments. Marker syntax is + * [^pN] where N is the passage id suffix (p1, p2, …). Unmatched or + * malformed markers remain as plain text. + */ +export function parseReportBody(body: string): ReportBodySegment[] { + const segments: ReportBodySegment[] = []; + let last = 0; + MARKER_RE.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = MARKER_RE.exec(body)) !== null) { + if (match.index > last) { + segments.push({ kind: "text", text: body.slice(last, match.index) }); + } + segments.push({ kind: "citation", passageId: `p${match[1]}` }); + last = match.index + match[0].length; + } + if (last < body.length) { + segments.push({ kind: "text", text: body.slice(last) }); + } + return segments; +} \ No newline at end of file diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index a3c25c3..5372e5b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -171,7 +171,8 @@ export type RunStatus = | "reflecting" | "synthesizing" | "verifying" - | "done"; + | "done" + | "cancelled"; /** The plan the engine halts on until approved (REQ-14 vocabulary). */ export interface RunPlan {