Merge pull request #722 from AssetOverflow/feat/wb-r3-replay-moment
feat(workbench): the Replay Moment — turn-keyed hero + retire W-026 artifact replay (Wave R3)
This commit is contained in:
commit
12df5dde43
22 changed files with 678 additions and 1113 deletions
112
docs/handoff/wave-R3-briefs-2026-06-13.md
Normal file
112
docs/handoff/wave-R3-briefs-2026-06-13.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# Wave R3 Briefs — Theater (the wow, honest)
|
||||
|
||||
Date: 2026-06-13
|
||||
Plan: `docs/workbench/wave-R-mastery-revamp.md` § Wave R3.
|
||||
Predecessor: Wave R2 complete (#714–#721; all six routes real). Replay
|
||||
backend already merged (#716, `GET /replay/{turn_id}` → `TurnReplayComparison`).
|
||||
|
||||
Four pieces. The Replay Moment is first (its backend exists and the old
|
||||
artifact-keyed `ReplayRoute` is now stale against it). The others are
|
||||
independent and can be authored after.
|
||||
|
||||
## Standing constraints (all briefs)
|
||||
|
||||
Same as Wave R2: worktree off fresh `origin/main`; green-local before push
|
||||
(`pnpm build && pnpm test`); **STOP after checks green, Shay merges**;
|
||||
token-only styling (hexScan); shrink-only `NOT_YET_MIRRORED`; conformance
|
||||
rows (ADR-0162 §6); no new mutation endpoints; no invented data; no
|
||||
force-directed / nondeterministic layout — ever.
|
||||
|
||||
---
|
||||
|
||||
## Brief R3-Replay — The Replay Moment (first; reworks + retires)
|
||||
|
||||
The hero flow. Turn-keyed sealed replay made *felt*: same prompt, same
|
||||
genesis substrate → bit-identical envelope, rendered as hash≡hash with an
|
||||
honest leaf diff. Consumes the merged #716 backend.
|
||||
|
||||
**This PR also retires the dead W-026 artifact-keyed replay machinery**
|
||||
(confirmed zero serving uses) on both sides — the `NOT_YET_MIRRORED` comment
|
||||
already anticipates it.
|
||||
|
||||
### Retirement surface (verified)
|
||||
|
||||
- **Python** (`workbench/schemas.py`): delete `ReplayDivergenceSeverity`,
|
||||
`ReplayStatus`, `ReplayDivergence`, `ReplayComparison` (the artifact-keyed
|
||||
block). Keep the `TurnReplay*` block. Zero word-boundary uses elsewhere.
|
||||
- **Snapshots**: regenerate both — `pnpm schema:snapshot` (drops
|
||||
`ReplayComparison`/`ReplayDivergence`) and `pnpm enum:snapshot` (drops
|
||||
`ReplayStatus`/`ReplayDivergenceSeverity`).
|
||||
- **TS** (`types/api.ts`): remove `ReplayComparison`/`ReplayDivergence`/
|
||||
`ReplayDivergenceSeverity`; add `TurnReplayComparison`/`TurnReplayDivergence`
|
||||
(+ the `TurnReplayDivergenceSeverity`/`TurnReplayBasis`/`TurnReplayOriginState`
|
||||
unions). Remove both `TurnReplay*` entries from `NOT_YET_MIRRORED`.
|
||||
- **Badges** (`design/components/badges/{types,mappings,index}.ts*`): remove
|
||||
`ReplayStatusBadge` + `ReplayDivergenceSeverityBadge` + their meta/types;
|
||||
drop their two cases from `enumCoverage.test.ts`. The hero renders the new
|
||||
severity (`critical`/`informational`) inline with a `≠` glyph — no new
|
||||
enum-tracked badge.
|
||||
- **API** (`client.ts`/`queries.ts`): remove `fetchReplayComparison`/
|
||||
`useReplayComparison`; add `fetchTurnReplay(turnId)` → `/replay/<turnId>` and
|
||||
`useTurnReplay(turnId)`.
|
||||
- **Route dir** (`app/replay/`): delete `ArtifactList`, `ReplayComparisonPanel`,
|
||||
`ReplayDiffViewer`, `ReplayMetadataTable`, and the old `replay.test.tsx`.
|
||||
|
||||
### New ReplayRoute (turn-keyed hero)
|
||||
|
||||
- List journaled turns (reuse `useTraceTurns`) in the left pane (VirtualizedList
|
||||
+ useListNavigation + SearchInput), same as Trace.
|
||||
- Select → navigate `/replay/<turnId>` (replace) → `useTurnReplay(turnId)`.
|
||||
- Hero: `original_trace_hash` vs `replay_trace_hash` rendered big; a clear
|
||||
`≡ equivalent` (when `equivalent`) or `≠ diverged` verdict. **Honesty card**
|
||||
states `comparison_basis` (`sealed_fresh_runtime_single_turn`) and
|
||||
`origin_state` (`unrecorded`) — a divergence means nondeterminism OR
|
||||
origin-state influence, never disambiguated; never render divergence as a
|
||||
determinism-failure verdict.
|
||||
- Leaf diff: each `divergence` as a row with `path`, original vs replay, and a
|
||||
`≠` glyph; `critical` weighted above `informational`; informational
|
||||
divergences (timestamp/cost/digest) explicitly labeled as expected.
|
||||
- Publishes the `turn` subject for the inspector (identity → detail), like Trace.
|
||||
- App route `path="replay/:turnId?"`; conformance row turn-keyed
|
||||
(loading "Loading turns...", empty "No turns recorded yet. Use Chat to
|
||||
create evidence." + `core chat`).
|
||||
- Tests: hero equivalence (hash≡hash), a tampered-leaf divergence renders `≠` at
|
||||
the right path, informational-only divergence still reads equivalent, the
|
||||
honesty fields render, j/k spine, replace-mode URL selection.
|
||||
|
||||
### Verification
|
||||
|
||||
```bash
|
||||
cd workbench-ui && pnpm build && pnpm test
|
||||
# plus the Python lane the snapshots feed:
|
||||
uv run python scripts/dump-schemas.py | diff - workbench-ui/schema-snapshot.json
|
||||
uv run python scripts/dump-enums.py | diff - workbench-ui/enum-snapshot.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Brief R3-DAG — Deterministic DAG viewer
|
||||
|
||||
One hand-rolled component (`design/components/Dag/`): longest-path layering,
|
||||
lexicographic tie-break, ~150 lines, **golden-file layout tests**, no graph
|
||||
dependency (force-directed = doctrine violation). Pan/zoom/click-node →
|
||||
inspector. Consumers: proposal chains, the 8 PCCP proof-promotion scenarios
|
||||
(`demos/proof_carrying_promotion`), entailment traces. Pure layout function;
|
||||
golden tests pin node coordinates.
|
||||
|
||||
---
|
||||
|
||||
## Brief R3-Demo — Demo Theater route
|
||||
|
||||
`GET /demos`, `POST /demos/{id}/run` (new read + a scoped run endpoint).
|
||||
Scenario results with evidence-class badges and "what this proves / what this
|
||||
does not prove" honesty cards; proposer-was-wrong scenarios visually
|
||||
highlighted. Backend brief first (Python), then the route.
|
||||
|
||||
---
|
||||
|
||||
## Brief R3-Ledger — wrong=0 ledger view
|
||||
|
||||
Evals renders the correct/refused/wrong triplet as the PRIMARY visualization;
|
||||
refusal reasons inspectable; failures-first ordering. Additive to the existing
|
||||
EvalsRoute — no retirement, lowest risk of the four.
|
||||
|
|
@ -55,8 +55,6 @@ snapshot = {
|
|||
"GroundingSource": literal_values(ROOT / "core" / "epistemic_state.py", "GroundingSource"),
|
||||
"NormativeClearance": enum_values(ROOT / "core" / "epistemic_state.py", "NormativeClearance"),
|
||||
"ReviewState": literal_values(ROOT / "teaching" / "proposals.py", "ReviewState"),
|
||||
"ReplayDivergenceSeverity": literal_values(ROOT / "workbench" / "schemas.py", "ReplayDivergenceSeverity"),
|
||||
"ReplayStatus": literal_values(ROOT / "workbench" / "schemas.py", "ReplayStatus"),
|
||||
}
|
||||
|
||||
print(json.dumps(snapshot, indent=2, sort_keys=True))
|
||||
|
|
|
|||
|
|
@ -30,17 +30,6 @@
|
|||
"unassessable",
|
||||
"suppressed"
|
||||
],
|
||||
"ReplayDivergenceSeverity": [
|
||||
"info",
|
||||
"warning",
|
||||
"failure"
|
||||
],
|
||||
"ReplayStatus": [
|
||||
"equivalent",
|
||||
"not_yet_replayed",
|
||||
"diverged",
|
||||
"evidence_unavailable"
|
||||
],
|
||||
"ReviewState": [
|
||||
"pending",
|
||||
"accepted",
|
||||
|
|
|
|||
|
|
@ -130,19 +130,6 @@
|
|||
"created_at",
|
||||
"downstream_effect"
|
||||
],
|
||||
"ReplayComparison": [
|
||||
"artifact_id",
|
||||
"original_hash",
|
||||
"replay_hash",
|
||||
"equivalent",
|
||||
"divergences"
|
||||
],
|
||||
"ReplayDivergence": [
|
||||
"path",
|
||||
"original",
|
||||
"replay",
|
||||
"severity"
|
||||
],
|
||||
"RunDetail": [
|
||||
"turns",
|
||||
"manifest"
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
EvalRunResult,
|
||||
ArtifactRef,
|
||||
ArtifactDetail,
|
||||
ReplayComparison,
|
||||
TurnReplayComparison,
|
||||
ProposalDetail,
|
||||
ProposalState,
|
||||
ProposalSummary,
|
||||
|
|
@ -92,8 +92,8 @@ export async function fetchArtifactDetail(artifactId: string): Promise<ArtifactD
|
|||
return apiFetch<ArtifactDetail>(`/artifacts/${artifactId}`);
|
||||
}
|
||||
|
||||
export async function fetchReplayComparison(artifactId: string): Promise<ReplayComparison> {
|
||||
return apiFetch<ReplayComparison>(`/replay/${artifactId}`);
|
||||
export async function fetchTurnReplay(turnId: number): Promise<TurnReplayComparison> {
|
||||
return apiFetch<TurnReplayComparison>(`/replay/${encodeURIComponent(String(turnId))}`);
|
||||
}
|
||||
|
||||
export type ProposalStateFilter = ProposalState | "all";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
runEvalLane,
|
||||
fetchArtifacts,
|
||||
fetchArtifactDetail,
|
||||
fetchReplayComparison,
|
||||
fetchTurnReplay,
|
||||
fetchProposalDetail,
|
||||
fetchProposals,
|
||||
fetchAuditEvents,
|
||||
|
|
@ -49,7 +49,7 @@ import type {
|
|||
EvalRunResult,
|
||||
ChatTurnResult,
|
||||
EvalRunRequest,
|
||||
ReplayComparison,
|
||||
TurnReplayComparison,
|
||||
MathProposalSummary,
|
||||
MathProposalDetail,
|
||||
MathRatifyResult,
|
||||
|
|
@ -99,11 +99,14 @@ export function useArtifactDetail(artifactId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export function useReplayComparison(artifactId: string) {
|
||||
return useQuery<ReplayComparison, WorkbenchApiError>({
|
||||
queryKey: ["api", "replay", artifactId],
|
||||
queryFn: () => fetchReplayComparison(artifactId),
|
||||
enabled: !!artifactId,
|
||||
export function useTurnReplay(turnId?: number | null) {
|
||||
return useQuery<TurnReplayComparison, WorkbenchApiError>({
|
||||
queryKey: ["api", "replay", "turn", turnId ?? null],
|
||||
queryFn: () => fetchTurnReplay(turnId as number),
|
||||
enabled: typeof turnId === "number",
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function App() {
|
|||
<Route index element={<Navigate to={`/${getWorkbenchPrefs().landingRoute}`} replace />} />
|
||||
<Route path="chat" element={<ChatRoute />} />
|
||||
<Route path="trace/:turnId?" element={<TraceRoute />} />
|
||||
<Route path="replay/:artifactId?" element={<ReplayRoute />} />
|
||||
<Route path="replay/:turnId?" element={<ReplayRoute />} />
|
||||
<Route path="proposals/:proposalId?" element={<ProposalsRoute />} />
|
||||
<Route path="evals/:laneId?" element={<EvalsRoute />} />
|
||||
<Route path="runs/:sessionId?" element={<RunsRoute />} />
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
import type { ArtifactRef } from "../../types/api";
|
||||
import { cn } from "../../design/lib";
|
||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||
import { useListNavigation } from "../../design/hooks/useListNavigation";
|
||||
|
||||
interface ArtifactListProps {
|
||||
artifacts: ArtifactRef[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
}
|
||||
|
||||
const KINDS = [
|
||||
"trace",
|
||||
"eval_result",
|
||||
"proposal",
|
||||
"contemplation_report",
|
||||
"telemetry",
|
||||
"engine_state_manifest",
|
||||
"unknown",
|
||||
] as const;
|
||||
|
||||
type ArtifactKind = (typeof KINDS)[number];
|
||||
|
||||
export function ArtifactList({ artifacts, selectedId, onSelect }: ArtifactListProps) {
|
||||
// Initialize grouped structure
|
||||
const grouped: Record<ArtifactKind, ArtifactRef[]> = {
|
||||
trace: [],
|
||||
eval_result: [],
|
||||
proposal: [],
|
||||
contemplation_report: [],
|
||||
telemetry: [],
|
||||
engine_state_manifest: [],
|
||||
unknown: [],
|
||||
};
|
||||
|
||||
artifacts.forEach((art) => {
|
||||
const kind = KINDS.includes(art.kind as ArtifactKind)
|
||||
? (art.kind as ArtifactKind)
|
||||
: "unknown";
|
||||
grouped[kind].push(art);
|
||||
});
|
||||
|
||||
// Sort each group
|
||||
KINDS.forEach((kind) => {
|
||||
grouped[kind].sort((a, b) => {
|
||||
if (a.created_at && b.created_at) {
|
||||
return b.created_at.localeCompare(a.created_at);
|
||||
}
|
||||
if (a.created_at) return -1;
|
||||
if (b.created_at) return 1;
|
||||
return a.artifact_id.localeCompare(b.artifact_id);
|
||||
});
|
||||
});
|
||||
|
||||
// Flat traversal order across groups for the keyboard spine (R0d).
|
||||
const flatOrder = KINDS.flatMap((kind) => grouped[kind]);
|
||||
const flatIndexById = new Map(flatOrder.map((a, i) => [a.artifact_id, i]));
|
||||
const { listProps, itemProps } = useListNavigation({
|
||||
itemCount: flatOrder.length,
|
||||
onActivate: (index) => {
|
||||
const art = flatOrder[index];
|
||||
if (art) onSelect(art.artifact_id);
|
||||
},
|
||||
});
|
||||
|
||||
if (artifacts.length === 0) {
|
||||
return (
|
||||
<div className="p-2" data-testid="artifact-list-empty">
|
||||
<EmptyState
|
||||
statement="No artifacts available."
|
||||
nextAction={{ kind: "cli", command: "core eval cognition" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="flex h-full flex-col gap-4 overflow-y-auto pr-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]"
|
||||
aria-label="Artifact list navigation"
|
||||
data-testid="artifact-list"
|
||||
{...listProps}
|
||||
>
|
||||
{KINDS.map((kind) => {
|
||||
const items = grouped[kind];
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={kind} className="space-y-1" data-testid={`group-${kind}`}>
|
||||
<h4 className="px-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
{kind.replace(/_/g, " ")}
|
||||
</h4>
|
||||
<div className="space-y-0.5">
|
||||
{items.map((art) => {
|
||||
const isSelected = art.artifact_id === selectedId;
|
||||
const { ref: rowRef, ...rowProps } = itemProps(
|
||||
flatIndexById.get(art.artifact_id) ?? 0,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={art.artifact_id}
|
||||
{...rowProps}
|
||||
ref={rowRef}
|
||||
onClick={() => onSelect(art.artifact_id)}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full rounded px-2 py-1.5 text-left focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-focus-ring)]",
|
||||
isSelected
|
||||
? "bg-[var(--color-selected-bg)] text-[var(--color-text-primary)] border-l-2 border-[var(--color-selected-border)] pl-1.5"
|
||||
: "text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text-primary)]"
|
||||
)}
|
||||
aria-current={isSelected ? "true" : undefined}
|
||||
data-testid={`artifact-${art.artifact_id}`}
|
||||
>
|
||||
<div className="truncate text-xs font-medium font-mono">
|
||||
{art.artifact_id}
|
||||
</div>
|
||||
{art.created_at && (
|
||||
<div className="text-[10px] text-[var(--color-text-muted)] mt-0.5">
|
||||
{new Date(art.created_at).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
import type { ArtifactDetail, ReplayComparison } from "../../types/api";
|
||||
import { ReplayStatusBadge, TraceHashBadge, ReplayStatus } from "../../design/components/badges";
|
||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||
import { ReplayDiffViewer } from "./ReplayDiffViewer";
|
||||
import { ReplayMetadataTable } from "./ReplayMetadataTable";
|
||||
|
||||
interface ReplayComparisonPanelProps {
|
||||
artifact: ArtifactDetail;
|
||||
comparison?: ReplayComparison | null;
|
||||
status: ReplayStatus;
|
||||
}
|
||||
|
||||
export function ReplayComparisonPanel({
|
||||
artifact,
|
||||
comparison,
|
||||
status,
|
||||
}: ReplayComparisonPanelProps) {
|
||||
const finalComparison: ReplayComparison = comparison || {
|
||||
artifact_id: artifact.artifact_id,
|
||||
original_hash: artifact.digest,
|
||||
replay_hash: null,
|
||||
equivalent: false,
|
||||
divergences: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="replay-comparison-panel">
|
||||
<div className="flex items-center justify-between border-b border-[var(--color-border-subtle)] pb-4">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)]">Replay Evidence</h2>
|
||||
<ReplayStatusBadge value={status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4 flex flex-col gap-2">
|
||||
<span className="text-[10px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wider">
|
||||
Original Hash
|
||||
</span>
|
||||
<div>
|
||||
{finalComparison.original_hash ? (
|
||||
<TraceHashBadge value={finalComparison.original_hash} />
|
||||
) : (
|
||||
<span className="text-xs font-mono text-[var(--color-text-muted)]">not_available</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4 flex flex-col gap-2">
|
||||
<span className="text-[10px] font-semibold text-[var(--color-text-muted)] uppercase tracking-wider">
|
||||
Replay Hash
|
||||
</span>
|
||||
<div>
|
||||
{finalComparison.replay_hash ? (
|
||||
<TraceHashBadge value={finalComparison.replay_hash} />
|
||||
) : (
|
||||
<span className="text-xs font-mono text-[var(--color-text-muted)]">not_available</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{status === "evidence_unavailable" ? (
|
||||
<EmptyState
|
||||
statement="Replay evidence is not available on the backend for this artifact kind."
|
||||
nextAction="Verify the backend replay path configuration."
|
||||
/>
|
||||
) : status === "equivalent" ? (
|
||||
<EmptyState
|
||||
statement="Replay evidence intact — no divergences."
|
||||
nextAction={{ kind: "cli", command: "core test --suite runtime" }}
|
||||
/>
|
||||
) : status === "not_yet_replayed" ? (
|
||||
<EmptyState
|
||||
statement="No replay has been attempted for this artifact yet."
|
||||
nextAction={{ kind: "cli", command: `core replay ${artifact.artifact_id}` }}
|
||||
/>
|
||||
) : (
|
||||
<ReplayDiffViewer divergences={finalComparison.divergences} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ReplayMetadataTable artifact={artifact} comparison={finalComparison} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
import type { ReplayDivergence } from "../../types/api";
|
||||
import { ReplayDivergenceSeverityBadge, ReplayDivergenceSeverity } from "../../design/components/badges";
|
||||
import { StableJsonViewer } from "../../design/components/StableJsonViewer";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Button } from "../../design/components/primitives/Button";
|
||||
|
||||
interface ReplayDiffViewerProps {
|
||||
divergences: ReplayDivergence[];
|
||||
}
|
||||
|
||||
const severityOrder: Record<"info" | "warning" | "failure", number> = {
|
||||
failure: 3,
|
||||
warning: 2,
|
||||
info: 1,
|
||||
};
|
||||
|
||||
function serializeValue(val: unknown): string {
|
||||
if (val === undefined) return "";
|
||||
try {
|
||||
return JSON.stringify(val);
|
||||
} catch {
|
||||
return String(val);
|
||||
}
|
||||
}
|
||||
|
||||
export function ReplayDiffViewer({ divergences }: ReplayDiffViewerProps) {
|
||||
if (divergences.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sortedDivergences = [...divergences].sort((a, b) => {
|
||||
const diff = severityOrder[b.severity] - severityOrder[a.severity];
|
||||
if (diff !== 0) return diff;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
const copyPath = (path: string) => {
|
||||
navigator.clipboard.writeText(path).catch(() => {
|
||||
// fail silently
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="replay-diff-viewer">
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">Replay Divergences</h3>
|
||||
<div className="space-y-4">
|
||||
{sortedDivergences.map((div) => {
|
||||
const key = `${div.severity}-${div.path}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4 space-y-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[var(--color-border-subtle)] pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ReplayDivergenceSeverityBadge value={div.severity as ReplayDivergenceSeverity} />
|
||||
<span className="font-mono text-xs text-[var(--color-text-secondary)] break-all select-all">
|
||||
{div.path}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => copyPath(div.path)}
|
||||
variant="quiet"
|
||||
type="button"
|
||||
aria-label={`Copy path ${div.path}`}
|
||||
>
|
||||
<Copy size={12} className="mr-1" />
|
||||
Copy Path
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Original Value
|
||||
</div>
|
||||
<StableJsonViewer source={serializeValue(div.original)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
||||
Replay Value
|
||||
</div>
|
||||
<StableJsonViewer source={serializeValue(div.replay)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
import type { ArtifactDetail, ReplayComparison } from "../../types/api";
|
||||
import { TraceHashBadge } from "../../design/components/badges";
|
||||
|
||||
interface ReplayMetadataTableProps {
|
||||
artifact: ArtifactDetail;
|
||||
comparison: ReplayComparison;
|
||||
}
|
||||
|
||||
export function ReplayMetadataTable({ artifact, comparison }: ReplayMetadataTableProps) {
|
||||
const divergenceCounts = comparison.divergences.reduce(
|
||||
(acc, div) => {
|
||||
acc[div.severity] = (acc[div.severity] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{ info: 0, warning: 0, failure: 0 } as Record<"info" | "warning" | "failure", number>
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-raised)] p-4">
|
||||
<h3 className="mb-3 text-sm font-semibold text-[var(--color-text-primary)]">Metadata Audit Details</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--color-border-subtle)] text-[var(--color-text-muted)]">
|
||||
<th className="pb-2 font-medium">Property</th>
|
||||
<th className="pb-2 font-medium">Value</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--color-border-subtle)] text-[var(--color-text-secondary)]">
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Artifact ID</td>
|
||||
<td className="py-2 font-mono">{artifact.artifact_id}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Kind</td>
|
||||
<td className="py-2">{artifact.kind}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Path</td>
|
||||
<td className="py-2 font-mono" data-testid="artifact-path-text">
|
||||
{artifact.path}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Original Hash</td>
|
||||
<td className="py-2">
|
||||
{comparison.original_hash ? (
|
||||
<TraceHashBadge value={comparison.original_hash} />
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Replay Hash</td>
|
||||
<td className="py-2">
|
||||
{comparison.replay_hash ? (
|
||||
<TraceHashBadge value={comparison.replay_hash} />
|
||||
) : (
|
||||
<span className="text-[var(--color-text-muted)]">None</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Divergences</td>
|
||||
<td className="py-2">
|
||||
<span className="inline-flex gap-2">
|
||||
<span className="text-[var(--color-review-rejected)]">
|
||||
Failure: {divergenceCounts.failure}
|
||||
</span>
|
||||
<span className="text-[var(--color-review-pending)]">
|
||||
Warning: {divergenceCounts.warning}
|
||||
</span>
|
||||
<span className="text-[var(--color-grounding-vault)]">
|
||||
Info: {divergenceCounts.info}
|
||||
</span>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 font-medium">Content Type</td>
|
||||
<td className="py-2 font-mono">{artifact.content_type}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
220
workbench-ui/src/app/replay/ReplayRoute.test.tsx
Normal file
220
workbench-ui/src/app/replay/ReplayRoute.test.tsx
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
MemoryRouter,
|
||||
Route,
|
||||
Routes,
|
||||
useLocation,
|
||||
useNavigationType,
|
||||
} from "react-router-dom";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestQueryClient } from "../../test/createTestQueryClient";
|
||||
import type { TurnJournalSummary, TurnReplayComparison } from "../../types/api";
|
||||
import { EvidenceProvider } from "../evidenceContext";
|
||||
import { ReplayRoute } from "./ReplayRoute";
|
||||
|
||||
const summaries: TurnJournalSummary[] = [
|
||||
{
|
||||
turn_id: 1,
|
||||
timestamp: "2026-06-12T18:00:00Z",
|
||||
prompt_excerpt: "What is truth?",
|
||||
surface_excerpt: "Truth is coherent.",
|
||||
trace_hash: "sha256:111111111111aaaa",
|
||||
grounding_source: "pack",
|
||||
},
|
||||
{
|
||||
turn_id: 2,
|
||||
timestamp: "2026-06-12T18:01:00Z",
|
||||
prompt_excerpt: "What is beauty?",
|
||||
surface_excerpt: "Beauty is form.",
|
||||
trace_hash: "sha256:222222222222bbbb",
|
||||
grounding_source: "teaching",
|
||||
},
|
||||
];
|
||||
|
||||
function comparisonFor(turnId: number): TurnReplayComparison {
|
||||
if (turnId === 1) {
|
||||
// equivalent, only an informational (wall-clock) divergence
|
||||
return {
|
||||
turn_id: 1,
|
||||
comparison_basis: "sealed_fresh_runtime_single_turn",
|
||||
origin_state: "unrecorded",
|
||||
original_trace_hash: "sha256:111111111111aaaa",
|
||||
replay_trace_hash: "sha256:111111111111aaaa",
|
||||
equivalent: true,
|
||||
replay_turn_cost_ms: 412,
|
||||
divergences: [
|
||||
{
|
||||
path: "timestamp",
|
||||
original: "2026-06-12T18:00:00Z",
|
||||
replay: "2026-06-13T00:00:00Z",
|
||||
severity: "informational",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// diverged: a critical surface divergence
|
||||
return {
|
||||
turn_id: 2,
|
||||
comparison_basis: "sealed_fresh_runtime_single_turn",
|
||||
origin_state: "unrecorded",
|
||||
original_trace_hash: "sha256:222222222222bbbb",
|
||||
replay_trace_hash: "sha256:999999999999cccc",
|
||||
equivalent: false,
|
||||
replay_turn_cost_ms: 401,
|
||||
divergences: [
|
||||
{
|
||||
path: "surface",
|
||||
original: "Beauty is form.",
|
||||
replay: "Beauty is symmetry.",
|
||||
severity: "critical",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function LocationProbe() {
|
||||
const location = useLocation();
|
||||
const navigationType = useNavigationType();
|
||||
return (
|
||||
<>
|
||||
<span data-testid="location">{`${location.pathname}${location.search}`}</span>
|
||||
<span data-testid="nav-type">{navigationType}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function renderRoute(initialEntry = "/replay") {
|
||||
return render(
|
||||
<QueryClientProvider client={createTestQueryClient()}>
|
||||
<MemoryRouter initialEntries={[initialEntry]}>
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/replay/:turnId?"
|
||||
element={
|
||||
<>
|
||||
<ReplayRoute />
|
||||
<LocationProbe />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</EvidenceProvider>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function stubReplayFetch(items: TurnJournalSummary[] = summaries) {
|
||||
const fetchMock = vi.fn((input: unknown) => {
|
||||
const path = new URL(String(input)).pathname;
|
||||
if (path === "/trace/turns") {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items } }),
|
||||
});
|
||||
}
|
||||
const match = path.match(/^\/replay\/(\d+)$/);
|
||||
if (match) {
|
||||
return Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({ ok: true, generated_at: "now", data: comparisonFor(Number(match[1])) }),
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
generated_at: "now",
|
||||
error: { code: "not_found", message: `unexpected ${path}` },
|
||||
}),
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
const offsetDescriptors = {
|
||||
offsetHeight: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"),
|
||||
offsetWidth: Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetWidth"),
|
||||
};
|
||||
|
||||
describe("ReplayRoute", () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get: () => 560,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get: () => 480,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (offsetDescriptors.offsetHeight) {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", offsetDescriptors.offsetHeight);
|
||||
}
|
||||
if (offsetDescriptors.offsetWidth) {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", offsetDescriptors.offsetWidth);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("lists journaled turns to replay", async () => {
|
||||
stubReplayFetch();
|
||||
renderRoute();
|
||||
expect(await screen.findByText("What is truth?")).toBeInTheDocument();
|
||||
expect(screen.getByText("What is beauty?")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the equivalence hero with the honesty card for a matching replay", async () => {
|
||||
stubReplayFetch();
|
||||
renderRoute("/replay/1");
|
||||
|
||||
expect(await screen.findByText(/Replay equivalent/)).toBeInTheDocument();
|
||||
// honesty fields are surfaced, not hidden
|
||||
expect(screen.getByText("sealed_fresh_runtime_single_turn")).toBeInTheDocument();
|
||||
expect(screen.getByText("unrecorded")).toBeInTheDocument();
|
||||
// an equivalent replay with only wall-clock drift is labeled as such
|
||||
expect(screen.getByText(/all informational/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a diverged verdict with the critical leaf for a tampered replay", async () => {
|
||||
stubReplayFetch();
|
||||
renderRoute("/replay/2");
|
||||
|
||||
expect(await screen.findByText("Replay diverged")).toBeInTheDocument();
|
||||
// the critical divergence is shown at its exact leaf path
|
||||
expect(screen.getByText("surface")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beauty is symmetry.")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("critical").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("selecting a turn writes /replay/<turnId> with replace", async () => {
|
||||
stubReplayFetch();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
await user.click(await screen.findByText("What is beauty?"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("location")).toHaveTextContent("/replay/2"));
|
||||
expect(screen.getByTestId("nav-type")).toHaveTextContent("REPLACE");
|
||||
expect(await screen.findByText("Replay diverged")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("moves the turn list focus with j/k through the VirtualizedList spine", async () => {
|
||||
stubReplayFetch();
|
||||
const user = userEvent.setup();
|
||||
renderRoute();
|
||||
|
||||
const list = await screen.findByRole("listbox", { name: "Replayable turns" });
|
||||
list.focus();
|
||||
expect(screen.getAllByRole("option")[0]).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await user.keyboard("j");
|
||||
expect(screen.getAllByRole("option")[1]).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,174 +1,321 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { useEvidenceSubject } from "../evidenceContext";
|
||||
import { subjectToUrl } from "../evidenceAddress";
|
||||
import { useArtifacts, useArtifactDetail, useReplayComparison } from "../../api/queries";
|
||||
import { ArtifactList } from "./ArtifactList";
|
||||
import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
|
||||
import { LoadingState } from "../../design/components/states/LoadingState";
|
||||
import { ErrorState } from "../../design/components/states/ErrorState";
|
||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { WorkbenchApiError } from "../../api/client";
|
||||
import { ReplayStatus } from "../../design/components/badges";
|
||||
import { useTraceTurns, useTurnReplay } from "../../api/queries";
|
||||
import { DigestBadge } from "../../design/components/DigestBadge/DigestBadge";
|
||||
import { Panel } from "../../design/components/Panel/Panel";
|
||||
import { SearchInput } from "../../design/components/SearchInput/SearchInput";
|
||||
import { SplitPane } from "../../design/components/SplitPane/SplitPane";
|
||||
import { Timestamp } from "../../design/components/Timestamp/Timestamp";
|
||||
import { VirtualizedList } from "../../design/components/VirtualizedList/VirtualizedList";
|
||||
import { EmptyState } from "../../design/components/states/EmptyState";
|
||||
import { ErrorState } from "../../design/components/states/ErrorState";
|
||||
import { LoadingState } from "../../design/components/states/LoadingState";
|
||||
import type {
|
||||
TurnJournalSummary,
|
||||
TurnReplayComparison,
|
||||
TurnReplayDivergence,
|
||||
} from "../../types/api";
|
||||
import { pushRecentItem } from "../commandRegistry";
|
||||
import { useEvidenceSubject } from "../evidenceContext";
|
||||
|
||||
export function ReplayRoute() {
|
||||
const { artifactId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
const selectedId = artifactId ?? null;
|
||||
const [search, setSearch] = useState("");
|
||||
function parseTurnId(raw: string | undefined): number | null {
|
||||
if (!raw || !/^\d+$/.test(raw)) return null;
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) ? value : null;
|
||||
}
|
||||
|
||||
const artifactsQuery = useArtifacts();
|
||||
const detailQuery = useArtifactDetail(selectedId || "");
|
||||
const comparisonQuery = useReplayComparison(selectedId || "");
|
||||
function errorMessage(error: unknown) {
|
||||
return error instanceof WorkbenchApiError ? error.message : "Replay request failed.";
|
||||
}
|
||||
|
||||
// Publish the selected artifact as the evidence subject: identity
|
||||
// immediately, detail once the query resolves.
|
||||
const detailData = detailQuery.data;
|
||||
useEffect(() => {
|
||||
if (!selectedId) return;
|
||||
setSubject({ kind: "artifact", artifactId: selectedId, data: detailData });
|
||||
}, [selectedId, detailData, setSubject]);
|
||||
function digestPayload(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
return value.replace(/^sha256:/, "");
|
||||
}
|
||||
|
||||
function handleSelect(id: string) {
|
||||
const search = searchParams.toString();
|
||||
const path = subjectToUrl({ kind: "artifact", artifactId: id });
|
||||
// Selection churn must not pollute history: replace, never push.
|
||||
navigate(search ? `${path}?${search}` : path, { replace: true });
|
||||
}
|
||||
|
||||
// Handle loading states
|
||||
const isLoadingArtifacts = artifactsQuery.isPending;
|
||||
const isLoadingDetail = selectedId ? detailQuery.isPending : false;
|
||||
const isLoadingComparison = selectedId ? comparisonQuery.isPending : false;
|
||||
|
||||
// Determine if comparison error is the unsupported (evidence_unavailable) case
|
||||
const isUnsupportedError =
|
||||
comparisonQuery.error instanceof WorkbenchApiError &&
|
||||
comparisonQuery.error.code === "unsupported";
|
||||
|
||||
// Check for genuine API errors
|
||||
const artifactsError = artifactsQuery.error;
|
||||
const detailError = detailQuery.error;
|
||||
const comparisonError = comparisonQuery.error;
|
||||
|
||||
const hasGenuineError =
|
||||
artifactsQuery.isError ||
|
||||
detailQuery.isError ||
|
||||
(comparisonQuery.isError && !isUnsupportedError);
|
||||
|
||||
const getGenuineErrorDetails = () => {
|
||||
if (artifactsQuery.isError && artifactsError) {
|
||||
return {
|
||||
message:
|
||||
artifactsError instanceof WorkbenchApiError
|
||||
? artifactsError.message
|
||||
: "Failed to load artifacts.",
|
||||
reproducer: "curl -X GET /artifacts",
|
||||
};
|
||||
}
|
||||
if (detailQuery.isError && detailError) {
|
||||
return {
|
||||
message:
|
||||
detailError instanceof WorkbenchApiError
|
||||
? detailError.message
|
||||
: "Failed to load artifact details.",
|
||||
reproducer: `curl -X GET /artifacts/${selectedId || ""}`,
|
||||
};
|
||||
}
|
||||
if (comparisonQuery.isError && comparisonError && !isUnsupportedError) {
|
||||
return {
|
||||
message:
|
||||
comparisonError instanceof WorkbenchApiError
|
||||
? comparisonError.message
|
||||
: "Failed to load replay comparison.",
|
||||
reproducer: `curl -X GET /replay/${selectedId || ""}`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const errorDetails = getGenuineErrorDetails();
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function TurnRow({
|
||||
turn,
|
||||
selected,
|
||||
focused,
|
||||
onSelect,
|
||||
}: {
|
||||
turn: TurnJournalSummary;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="grid grid-cols-[18rem_1fr] h-[calc(100vh-8rem)] gap-4 overflow-hidden"
|
||||
data-testid="replay-theater-route"
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
onClick={onSelect}
|
||||
className={`grid w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-3 border-b border-[var(--color-border-subtle)] px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-inset)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-inset focus-visible:outline-[var(--color-focus-ring)] ${
|
||||
selected ? "bg-[var(--color-selected-bg)]" : ""
|
||||
} ${
|
||||
selected
|
||||
? "border-l-2 border-l-[var(--color-selected-border)] pl-[10px]"
|
||||
: focused
|
||||
? "border-l-2 border-l-[var(--color-focus-ring)] pl-[10px]"
|
||||
: "border-l-2 border-l-transparent pl-[10px]"
|
||||
}`}
|
||||
>
|
||||
{/* Left Pane: Artifact list */}
|
||||
<div className="border-r border-[var(--color-border-subtle)] overflow-y-auto pr-2">
|
||||
<div className="mb-2">
|
||||
<SearchInput
|
||||
placeholder="Filter by artifact id or kind"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
</div>
|
||||
{isLoadingArtifacts ? (
|
||||
<LoadingState label="Loading artifacts..." />
|
||||
) : artifactsQuery.isError ? (
|
||||
<ErrorState
|
||||
whatFailed={
|
||||
artifactsError instanceof WorkbenchApiError
|
||||
? artifactsError.message
|
||||
: "Failed to load artifacts."
|
||||
}
|
||||
mutationStatus="No corpus mutation occurred."
|
||||
reproducer="curl http://127.0.0.1:8765/artifacts"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : (
|
||||
<ArtifactList
|
||||
artifacts={(artifactsQuery.data || []).filter((a) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
a.artifact_id.toLowerCase().includes(q) ||
|
||||
a.kind.toLowerCase().includes(q)
|
||||
);
|
||||
})}
|
||||
selectedId={selectedId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Pane: Replay detail comparison */}
|
||||
<div className="overflow-y-auto pl-2 pr-4">
|
||||
{hasGenuineError && errorDetails ? (
|
||||
<ErrorState
|
||||
whatFailed={errorDetails.message}
|
||||
mutationStatus="No corpus mutation occurred."
|
||||
reproducer={errorDetails.reproducer}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : isLoadingDetail || isLoadingComparison ? (
|
||||
<LoadingState
|
||||
label={isLoadingComparison ? "Comparing artifacts..." : "Comparing artifacts..."}
|
||||
/>
|
||||
) : !selectedId ? (
|
||||
<EmptyState
|
||||
statement="No artifact selected."
|
||||
nextAction="Select an artifact from the list to inspect replay evidence."
|
||||
/>
|
||||
) : detailQuery.data ? (
|
||||
<ReplayComparisonPanel
|
||||
artifact={detailQuery.data}
|
||||
comparison={isUnsupportedError ? null : comparisonQuery.data}
|
||||
status={
|
||||
isUnsupportedError
|
||||
? ReplayStatus.EVIDENCE_UNAVAILABLE
|
||||
: comparisonQuery.data?.equivalent
|
||||
? ReplayStatus.EQUIVALENT
|
||||
: comparisonQuery.data?.replay_hash === null
|
||||
? ReplayStatus.NOT_YET_REPLAYED
|
||||
: ReplayStatus.DIVERGED
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs text-[var(--color-text-secondary)]">
|
||||
<Timestamp iso={turn.timestamp} format="relative" />
|
||||
</span>
|
||||
<span className="mt-1 block truncate text-sm text-[var(--color-text-primary)]">
|
||||
{turn.prompt_excerpt || `Turn #${turn.turn_id}`}
|
||||
</span>
|
||||
</span>
|
||||
<span className="justify-self-end font-mono text-xs text-[var(--color-text-muted)]">
|
||||
#{turn.turn_id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HashPair({ comparison }: { comparison: TurnReplayComparison }) {
|
||||
const original = digestPayload(comparison.original_trace_hash);
|
||||
const replay = digestPayload(comparison.replay_trace_hash);
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-2 text-sm">
|
||||
<span className="text-[var(--color-text-secondary)]">original</span>
|
||||
<span>{original ? <DigestBadge digest={original} truncate={16} /> : "not recorded"}</span>
|
||||
<span className="text-[var(--color-text-secondary)]">replay</span>
|
||||
<span>{replay ? <DigestBadge digest={replay} truncate={16} /> : "not recorded"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Verdict({ equivalent }: { equivalent: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
equivalent
|
||||
? "border-[var(--color-state-verified)] text-[var(--color-state-verified)]"
|
||||
: "border-[var(--color-state-contradicted)] text-[var(--color-state-contradicted)]"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="font-mono text-lg">
|
||||
{equivalent ? "≡" : "≠"}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{equivalent ? "Replay equivalent — bit-identical envelope" : "Replay diverged"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DivergenceRow({ divergence }: { divergence: TurnReplayDivergence }) {
|
||||
const critical = divergence.severity === "critical";
|
||||
return (
|
||||
<li
|
||||
className={`grid gap-1 rounded-md border px-3 py-2 ${
|
||||
critical
|
||||
? "border-[var(--color-state-contradicted)] bg-[var(--color-surface-inset)]"
|
||||
: "border-[var(--color-border-subtle)]"
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span aria-hidden className="font-mono text-[var(--color-text-muted)]">
|
||||
≠
|
||||
</span>
|
||||
<span className="font-mono text-xs text-[var(--color-text-primary)]">{divergence.path}</span>
|
||||
<span
|
||||
className={`text-[10px] uppercase tracking-wide ${
|
||||
critical ? "text-[var(--color-state-contradicted)]" : "text-[var(--color-text-muted)]"
|
||||
}`}
|
||||
>
|
||||
{divergence.severity}
|
||||
</span>
|
||||
</span>
|
||||
<span className="grid grid-cols-[auto_1fr] gap-x-2 font-mono text-xs">
|
||||
<span className="text-[var(--color-text-secondary)]">original</span>
|
||||
<span className="break-all text-[var(--color-text-primary)]">
|
||||
{stringify(divergence.original)}
|
||||
</span>
|
||||
<span className="text-[var(--color-text-secondary)]">replay</span>
|
||||
<span className="break-all text-[var(--color-text-primary)]">
|
||||
{stringify(divergence.replay)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function ReplayHero({ comparison }: { comparison: TurnReplayComparison }) {
|
||||
// critical first, then informational; stable within a severity by path.
|
||||
const ordered = useMemo(() => {
|
||||
const weight = (s: string) => (s === "critical" ? 0 : 1);
|
||||
return [...comparison.divergences].sort(
|
||||
(a, b) => weight(a.severity) - weight(b.severity) || a.path.localeCompare(b.path),
|
||||
);
|
||||
}, [comparison.divergences]);
|
||||
const informationalOnly =
|
||||
comparison.divergences.length > 0 &&
|
||||
comparison.divergences.every((d) => d.severity === "informational");
|
||||
|
||||
return (
|
||||
<Panel title={`Replay of Turn #${comparison.turn_id}`}>
|
||||
<div className="grid gap-4">
|
||||
<Verdict equivalent={comparison.equivalent} />
|
||||
<HashPair comparison={comparison} />
|
||||
|
||||
<section className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface-inset)] p-3">
|
||||
<h3 className="m-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
What this proves
|
||||
</h3>
|
||||
<p className="mt-1 mb-0 text-xs text-[var(--color-text-muted)]">
|
||||
Basis: <span className="font-mono">{comparison.comparison_basis}</span>. The prompt was
|
||||
re-executed in a sealed fresh runtime. Origin state is{" "}
|
||||
<span className="font-mono">{comparison.origin_state}</span> — the journal does not
|
||||
record whether the original turn loaded a checkpoint, so a divergence means
|
||||
nondeterminism <em>or</em> origin-state influence; it is not, on its own, a determinism
|
||||
failure.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{comparison.divergences.length === 0 ? (
|
||||
<p className="m-0 text-sm text-[var(--color-text-secondary)]">
|
||||
No divergences — every leaf of the envelope matched.
|
||||
</p>
|
||||
) : (
|
||||
<section className="grid gap-2">
|
||||
<h3 className="m-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
Leaf divergences ({comparison.divergences.length})
|
||||
{informationalOnly ? " — all informational (wall-clock; expected)" : ""}
|
||||
</h3>
|
||||
<ul className="m-0 grid list-none gap-2 p-0">
|
||||
{ordered.map((divergence) => (
|
||||
<DivergenceRow key={divergence.path} divergence={divergence} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReplayRoute() {
|
||||
const { turnId } = useParams();
|
||||
const selectedTurnId = parseTurnId(turnId);
|
||||
const navigate = useNavigate();
|
||||
const { setSubject } = useEvidenceSubject();
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const turnsQuery = useTraceTurns();
|
||||
const replayQuery = useTurnReplay(selectedTurnId);
|
||||
|
||||
const turns = turnsQuery.data ?? [];
|
||||
const filteredTurns = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return turns;
|
||||
return turns.filter(
|
||||
(turn) =>
|
||||
turn.prompt_excerpt.toLowerCase().includes(q) || String(turn.turn_id).includes(q),
|
||||
);
|
||||
}, [search, turns]);
|
||||
|
||||
// Publish the turn subject for the inspector.
|
||||
useEffect(() => {
|
||||
if (selectedTurnId === null) return;
|
||||
setSubject({ kind: "turn", turnId: selectedTurnId });
|
||||
}, [selectedTurnId, setSubject]);
|
||||
|
||||
function selectTurn(turn: TurnJournalSummary) {
|
||||
navigate(`/replay/${turn.turn_id}`, { replace: true });
|
||||
pushRecentItem({ label: `Replay #${turn.turn_id}`, path: `/replay/${turn.turn_id}` });
|
||||
}
|
||||
|
||||
if (turnsQuery.isLoading) {
|
||||
return <LoadingState label="Loading turns..." />;
|
||||
}
|
||||
|
||||
if (turnsQuery.isError) {
|
||||
return (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(turnsQuery.error)}
|
||||
mutationStatus="No replay mutation occurred."
|
||||
reproducer="curl /trace/turns"
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (turns.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
statement="No turns recorded yet. Use Chat to create evidence."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full min-h-0">
|
||||
<SplitPane direction="horizontal" id="replay" defaultSplit={36} minSize={320}>
|
||||
<Panel title="Replayable turns">
|
||||
<div className="grid min-h-0 gap-3">
|
||||
<SearchInput
|
||||
placeholder="Filter by prompt or turn id"
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
/>
|
||||
{filteredTurns.length === 0 ? (
|
||||
<EmptyState
|
||||
statement="No turns match this filter."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
) : (
|
||||
<VirtualizedList
|
||||
ariaLabel="Replayable turns"
|
||||
estimateSize={72}
|
||||
getKey={(turn) => String(turn.turn_id)}
|
||||
height="calc(100vh - 14rem)"
|
||||
initialRect={{ width: 480, height: 560 }}
|
||||
items={filteredTurns}
|
||||
onActivate={(turn) => selectTurn(turn)}
|
||||
renderItem={(turn, _index, focused) => (
|
||||
<TurnRow
|
||||
turn={turn}
|
||||
selected={turn.turn_id === selectedTurnId}
|
||||
focused={focused}
|
||||
onSelect={() => selectTurn(turn)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<section className="h-full min-h-0 overflow-y-auto pl-3">
|
||||
{selectedTurnId === null ? (
|
||||
<EmptyState
|
||||
statement="Select a turn to replay it deterministically and compare hashes."
|
||||
nextAction={{ kind: "cli", command: "core chat" }}
|
||||
/>
|
||||
) : replayQuery.isLoading ? (
|
||||
<LoadingState label="Replaying turn..." />
|
||||
) : replayQuery.isError ? (
|
||||
<ErrorState
|
||||
whatFailed={errorMessage(replayQuery.error)}
|
||||
mutationStatus="No replay mutation occurred."
|
||||
reproducer={`curl /replay/${selectedTurnId}`}
|
||||
retrySafety="Retry: safe"
|
||||
/>
|
||||
) : replayQuery.data ? (
|
||||
<ReplayHero comparison={replayQuery.data} />
|
||||
) : null}
|
||||
</section>
|
||||
</SplitPane>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,414 +0,0 @@
|
|||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { createTestQueryClient } from "../../test/createTestQueryClient";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||
import { EvidenceProvider } from "../evidenceContext";
|
||||
import { ArtifactList } from "./ArtifactList";
|
||||
import { ReplayStatusBadge, ReplayStatus } from "../../design/components/badges";
|
||||
import { ReplayComparisonPanel } from "./ReplayComparisonPanel";
|
||||
import { ReplayDiffViewer } from "./ReplayDiffViewer";
|
||||
import { ReplayMetadataTable } from "./ReplayMetadataTable";
|
||||
import { ReplayRoute } from "./ReplayRoute";
|
||||
import type { ArtifactRef, ArtifactDetail, ReplayComparison } from "../../types/api";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Mock globals
|
||||
vi.stubGlobal("navigator", {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
|
||||
const mockArtifacts: ArtifactRef[] = [
|
||||
{
|
||||
artifact_id: "art-trace-1",
|
||||
kind: "trace",
|
||||
path: "traces/1.json",
|
||||
digest: "sha256:1",
|
||||
created_at: "2026-05-26T12:00:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-eval-1",
|
||||
kind: "eval_result",
|
||||
path: "evals/1.json",
|
||||
digest: "sha256:2",
|
||||
created_at: "2026-05-26T12:01:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-prop-1",
|
||||
kind: "proposal",
|
||||
path: "proposals/1.json",
|
||||
digest: "sha256:3",
|
||||
created_at: "2026-05-26T12:02:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-rep-1",
|
||||
kind: "contemplation_report",
|
||||
path: "reports/1.json",
|
||||
digest: "sha256:4",
|
||||
created_at: "2026-05-26T12:03:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-tel-1",
|
||||
kind: "telemetry",
|
||||
path: "telemetry/1.jsonl",
|
||||
digest: "sha256:5",
|
||||
created_at: "2026-05-26T12:04:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-state-1",
|
||||
kind: "engine_state_manifest",
|
||||
path: "state/1.json",
|
||||
digest: "sha256:6",
|
||||
created_at: "2026-05-26T12:05:00Z",
|
||||
},
|
||||
{
|
||||
artifact_id: "art-unk-1",
|
||||
kind: "unknown",
|
||||
path: "unknown/1.json",
|
||||
digest: "sha256:7",
|
||||
created_at: "2026-05-26T12:06:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const mockArtifactDetail: ArtifactDetail = {
|
||||
artifact_id: "art-trace-1",
|
||||
kind: "trace",
|
||||
path: "traces/1.json",
|
||||
digest: "sha256:1",
|
||||
created_at: "2026-05-26T12:00:00Z",
|
||||
content_type: "json",
|
||||
content: { hello: "world" },
|
||||
};
|
||||
|
||||
const mockReplayComparison: ReplayComparison = {
|
||||
artifact_id: "art-trace-1",
|
||||
original_hash: "sha256:orig-1234",
|
||||
replay_hash: "sha256:repl-5678",
|
||||
equivalent: false,
|
||||
divergences: [
|
||||
{ path: "/a", original: 1, replay: 2, severity: "warning" },
|
||||
{ path: "/b", original: true, replay: false, severity: "failure" },
|
||||
{ path: "/c", original: "x", replay: "y", severity: "info" },
|
||||
],
|
||||
};
|
||||
|
||||
function renderWithProviders(ui: React.ReactElement, initialEntries = ["/"]) {
|
||||
const queryClient = createTestQueryClient();
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MemoryRouter initialEntries={initialEntries}>{ui}</MemoryRouter>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// ReplayRoute reads its selection from the :artifactId path param and
|
||||
// publishes the selected artifact as the evidence subject, so it needs the
|
||||
// real route declaration and an EvidenceProvider.
|
||||
function renderReplayRoute(initialEntry: string) {
|
||||
return renderWithProviders(
|
||||
<EvidenceProvider>
|
||||
<Routes>
|
||||
<Route path="/replay/:artifactId?" element={<ReplayRoute />} />
|
||||
</Routes>
|
||||
</EvidenceProvider>,
|
||||
[initialEntry],
|
||||
);
|
||||
}
|
||||
|
||||
describe("W-031 Replay Theater Tests", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("ArtifactList", () => {
|
||||
it("renders empty state when API returns []", () => {
|
||||
render(
|
||||
<ArtifactList artifacts={[]} selectedId={null} onSelect={() => {}} />
|
||||
);
|
||||
expect(screen.getByTestId("artifact-list-empty")).toBeInTheDocument();
|
||||
expect(screen.getByText("No artifacts available.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("groups by kind correctly with a fixture covering all 7 kind values", () => {
|
||||
render(
|
||||
<ArtifactList
|
||||
artifacts={mockArtifacts}
|
||||
selectedId={null}
|
||||
onSelect={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Verify that all 7 group headings are rendered
|
||||
expect(screen.getByText("trace")).toBeInTheDocument();
|
||||
expect(screen.getByText("eval result")).toBeInTheDocument();
|
||||
expect(screen.getByText("proposal")).toBeInTheDocument();
|
||||
expect(screen.getByText("contemplation report")).toBeInTheDocument();
|
||||
expect(screen.getByText("telemetry")).toBeInTheDocument();
|
||||
expect(screen.getByText("engine state manifest")).toBeInTheDocument();
|
||||
expect(screen.getByText("unknown")).toBeInTheDocument();
|
||||
|
||||
// Verify artifact IDs are present
|
||||
mockArtifacts.forEach((art) => {
|
||||
expect(screen.getByText(art.artifact_id)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("selecting a row invokes onSelect with the artifact id", () => {
|
||||
// URL writing is ReplayRoute's job (path param, replace) — covered by
|
||||
// the route tests below. ArtifactList only reports the selection.
|
||||
const onSelect = vi.fn();
|
||||
render(
|
||||
<ArtifactList
|
||||
artifacts={mockArtifacts}
|
||||
selectedId={null}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("artifact-art-trace-1"));
|
||||
expect(onSelect).toHaveBeenCalledWith("art-trace-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReplayStatusBadge", () => {
|
||||
it("all four enum states render with the right label and color semantics", () => {
|
||||
const states = [
|
||||
{ value: ReplayStatus.EQUIVALENT, label: "equivalent" },
|
||||
{ value: ReplayStatus.NOT_YET_REPLAYED, label: "not_yet_replayed" },
|
||||
{ value: ReplayStatus.DIVERGED, label: "diverged" },
|
||||
{ value: ReplayStatus.EVIDENCE_UNAVAILABLE, label: "evidence_unavailable" },
|
||||
];
|
||||
|
||||
states.forEach(({ value, label }) => {
|
||||
const { unmount } = render(<ReplayStatusBadge value={value} />);
|
||||
expect(screen.getByRole("button", { name: label })).toBeInTheDocument();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReplayComparisonPanel", () => {
|
||||
it("equivalent=true renders the calm empty-state for divergences, NOT a celebratory affordance", () => {
|
||||
const eqComparison: ReplayComparison = {
|
||||
artifact_id: "art-trace-1",
|
||||
original_hash: "sha256:1",
|
||||
replay_hash: "sha256:1",
|
||||
equivalent: true,
|
||||
divergences: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<ReplayComparisonPanel
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={eqComparison}
|
||||
status={ReplayStatus.EQUIVALENT}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check for calm empty state text
|
||||
expect(screen.getByText("Replay evidence intact — no divergences.")).toBeInTheDocument();
|
||||
|
||||
// Assert static text contains no celebratory affect
|
||||
const containerText = document.body.innerHTML;
|
||||
const forbiddenWords = ["success", "celebrate", "congratulations", "🎉", "✅", "🏆", "🌟"];
|
||||
forbiddenWords.forEach((word) => {
|
||||
expect(containerText.toLowerCase()).not.toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies the component source code files contain no celebratory words or emojis", () => {
|
||||
const dir = __dirname;
|
||||
const files = [
|
||||
"ReplayRoute.tsx",
|
||||
"ReplayComparisonPanel.tsx",
|
||||
"ReplayDiffViewer.tsx",
|
||||
"ReplayMetadataTable.tsx",
|
||||
"ArtifactList.tsx",
|
||||
];
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dir, file);
|
||||
const code = fs.readFileSync(filePath, "utf-8");
|
||||
const forbiddenWords = ["success", "celebrate", "congratulations", "🎉", "✅"];
|
||||
forbiddenWords.forEach((word) => {
|
||||
expect(
|
||||
code.toLowerCase(),
|
||||
`File ${file} contains forbidden positive-affect word: ${word}`
|
||||
).not.toContain(word);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReplayDiffViewer", () => {
|
||||
it("renders divergences ordered failure -> warning -> info", () => {
|
||||
render(<ReplayDiffViewer divergences={mockReplayComparison.divergences} />);
|
||||
|
||||
const badges = screen.getAllByRole("button", { name: /(failure|warning|info)/i });
|
||||
expect(badges).toHaveLength(3);
|
||||
expect(badges[0].textContent).toBe("failure");
|
||||
expect(badges[1].textContent).toBe("warning");
|
||||
expect(badges[2].textContent).toBe("info");
|
||||
});
|
||||
|
||||
it("renders nothing (null) with 0 divergences", () => {
|
||||
const { container } = render(<ReplayDiffViewer divergences={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReplayMetadataTable", () => {
|
||||
it("copyable digests work", () => {
|
||||
render(
|
||||
<ReplayMetadataTable
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={mockReplayComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
// Check presence of digest badges (which are copyable InfoBadges)
|
||||
const origBadge = screen.getByRole("button", { name: "sha256:orig-" });
|
||||
const replBadge = screen.getByRole("button", { name: "sha256:repl-" });
|
||||
|
||||
expect(origBadge).toBeInTheDocument();
|
||||
expect(replBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rendered path is text-only, not an anchor/link element", () => {
|
||||
render(
|
||||
<ReplayMetadataTable
|
||||
artifact={mockArtifactDetail}
|
||||
comparison={mockReplayComparison}
|
||||
/>
|
||||
);
|
||||
|
||||
const pathEl = screen.getByTestId("artifact-path-text");
|
||||
expect(pathEl.tagName.toLowerCase()).not.toBe("a");
|
||||
expect(pathEl.closest("a")).toBeNull();
|
||||
expect(pathEl.textContent).toBe(mockArtifactDetail.path);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ReplayRoute", () => {
|
||||
it("full happy-path with fixture artifact + fixture comparison", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith("/artifacts")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/artifacts/art-trace-1")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/replay/art-trace-1")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockReplayComparison }),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("Unknown route"));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
// Should load list, detail, and comparison
|
||||
expect(await screen.findByText("Replay Evidence")).toBeInTheDocument();
|
||||
expect(screen.getAllByText("art-trace-1").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("Replay Divergences")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("evidence_unavailable state when backend returns unsupported", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith("/artifacts")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/artifacts/art-trace-1")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/replay/art-trace-1")) {
|
||||
// Return unsupported error code
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
ok: false,
|
||||
generated_at: "now",
|
||||
error: { code: "unsupported", message: "route is unsupported" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("Unknown route"));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
expect(await screen.findByText("evidence_unavailable")).toBeInTheDocument();
|
||||
expect(screen.getByText("Replay evidence is not available on the backend for this artifact kind.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ErrorState only for genuine API errors (not for unsupported)", async () => {
|
||||
const fetchMock = vi.fn().mockImplementation((url: string) => {
|
||||
if (url.endsWith("/artifacts")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: { items: mockArtifacts } }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/artifacts/art-trace-1")) {
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({ ok: true, generated_at: "now", data: mockArtifactDetail }),
|
||||
});
|
||||
}
|
||||
if (url.endsWith("/replay/art-trace-1")) {
|
||||
// Return a genuine read_error
|
||||
return Promise.resolve({
|
||||
json: () => Promise.resolve({
|
||||
ok: false,
|
||||
generated_at: "now",
|
||||
error: { code: "read_error", message: "disk read error" },
|
||||
}),
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error("Unknown route"));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderReplayRoute("/replay/art-trace-1");
|
||||
|
||||
expect(await screen.findByText("What failed")).toBeInTheDocument();
|
||||
expect(screen.getByText("disk read error")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Anti-motion & Animation Constraints", () => {
|
||||
it("verifies the component files contain no animation/motion triggers like transition, animate, fade", () => {
|
||||
const dir = __dirname;
|
||||
const files = [
|
||||
"ReplayRoute.tsx",
|
||||
"ReplayComparisonPanel.tsx",
|
||||
"ReplayDiffViewer.tsx",
|
||||
"ReplayMetadataTable.tsx",
|
||||
"ArtifactList.tsx",
|
||||
];
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(dir, file);
|
||||
const code = fs.readFileSync(filePath, "utf-8");
|
||||
const forbiddenMotion = ["transition", "animate", "fade"];
|
||||
forbiddenMotion.forEach((trigger) => {
|
||||
expect(
|
||||
code.toLowerCase(),
|
||||
`File ${file} contains forbidden animation keyword: ${trigger}`
|
||||
).not.toContain(trigger);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -158,11 +158,11 @@ const MOUNT_ROUTES: MountRouteSpec[] = [
|
|||
{
|
||||
name: "Replay",
|
||||
element: <ReplayRoute />,
|
||||
path: "/replay/:artifactId?",
|
||||
path: "/replay/:turnId?",
|
||||
initialEntry: "/replay",
|
||||
loadingLabel: "Loading artifacts...",
|
||||
emptyStatement: "No artifacts available.",
|
||||
emptyCommand: "core eval cognition",
|
||||
loadingLabel: "Loading turns...",
|
||||
emptyStatement: "No turns recorded yet. Use Chat to create evidence.",
|
||||
emptyCommand: "core chat",
|
||||
},
|
||||
{
|
||||
name: "Packs",
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import {
|
|||
groundingSourceMeta,
|
||||
normativeClearanceMeta,
|
||||
reviewStateMeta,
|
||||
replayDivergenceSeverityMeta,
|
||||
replayStatusMeta,
|
||||
} from "./mappings";
|
||||
|
||||
function expectExactCoverage(name: string, engineValues: string[], uiValues: string[]) {
|
||||
|
|
@ -45,20 +43,4 @@ describe("build-time enum coverage", () => {
|
|||
Object.keys(groundingSourceMeta),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks every ratified ReplayDivergenceSeverity value exactly once", () => {
|
||||
expectExactCoverage(
|
||||
"ReplayDivergenceSeverity",
|
||||
snapshot.ReplayDivergenceSeverity,
|
||||
Object.keys(replayDivergenceSeverityMeta),
|
||||
);
|
||||
});
|
||||
|
||||
it("tracks every ratified ReplayStatus value exactly once", () => {
|
||||
expectExactCoverage(
|
||||
"ReplayStatus",
|
||||
snapshot.ReplayStatus,
|
||||
Object.keys(replayStatusMeta),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
groundingSourceMeta,
|
||||
normativeClearanceMeta,
|
||||
reviewStateMeta,
|
||||
replayDivergenceSeverityMeta,
|
||||
replayStatusMeta,
|
||||
} from "./mappings";
|
||||
import { InfoBadge } from "./Badge";
|
||||
import {
|
||||
|
|
@ -12,8 +10,6 @@ import {
|
|||
GroundingSource,
|
||||
NormativeClearance,
|
||||
ReviewState,
|
||||
ReplayDivergenceSeverity,
|
||||
ReplayStatus,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
|
|
@ -21,8 +17,6 @@ export {
|
|||
GroundingSource,
|
||||
NormativeClearance,
|
||||
ReviewState,
|
||||
ReplayDivergenceSeverity,
|
||||
ReplayStatus,
|
||||
};
|
||||
|
||||
export function EpistemicStateBadge({ value }: { value: EpistemicState }) {
|
||||
|
|
@ -41,14 +35,6 @@ export function GroundingSourceBadge({ value }: { value: GroundingSource }) {
|
|||
return <InfoBadge {...groundingSourceMeta[value]} />;
|
||||
}
|
||||
|
||||
export function ReplayDivergenceSeverityBadge({ value }: { value: ReplayDivergenceSeverity }) {
|
||||
return <InfoBadge {...replayDivergenceSeverityMeta[value]} />;
|
||||
}
|
||||
|
||||
export function ReplayStatusBadge({ value }: { value: ReplayStatus }) {
|
||||
return <InfoBadge {...replayStatusMeta[value]} />;
|
||||
}
|
||||
|
||||
export function TraceHashBadge({
|
||||
value,
|
||||
truncate = 12,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import {
|
|||
GroundingSource,
|
||||
NormativeClearance,
|
||||
ReviewState,
|
||||
ReplayDivergenceSeverity,
|
||||
ReplayStatus,
|
||||
type BadgeMeta,
|
||||
} from "./types";
|
||||
|
||||
|
|
@ -48,16 +46,3 @@ export const groundingSourceMeta = {
|
|||
[GroundingSource.OOV]: { label: "OOV", colorToken: "--color-grounding-oov", meaning: "Out-of-vocabulary grounding was encountered.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is oov." },
|
||||
[GroundingSource.NONE]: { label: "None", colorToken: "--color-grounding-none", meaning: "No grounding source was present.", adr: "ADR-0160 / ADR-0162", evidence: "grounding_source is none." },
|
||||
} satisfies BadgeMeta<GroundingSource>;
|
||||
|
||||
export const replayDivergenceSeverityMeta = {
|
||||
[ReplayDivergenceSeverity.INFO]: { label: "info", colorToken: "--color-grounding-vault", meaning: "Informational divergence, non-critical deviation.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is info." },
|
||||
[ReplayDivergenceSeverity.WARNING]: { label: "warning", colorToken: "--color-review-pending", meaning: "Warning divergence, requires attention.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is warning." },
|
||||
[ReplayDivergenceSeverity.FAILURE]: { label: "failure", colorToken: "--color-review-rejected", meaning: "Failure divergence, critical replay violation.", adr: "ADR-0160 / ADR-0162", evidence: "divergence severity is failure." },
|
||||
} satisfies BadgeMeta<ReplayDivergenceSeverity>;
|
||||
|
||||
export const replayStatusMeta = {
|
||||
[ReplayStatus.EQUIVALENT]: { label: "equivalent", colorToken: "--color-review-accepted", meaning: "Replay evidence intact — no divergences.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is true and replay_hash is present." },
|
||||
[ReplayStatus.NOT_YET_REPLAYED]: { label: "not_yet_replayed", colorToken: "--color-review-pending", meaning: "No replay has been attempted for this artifact yet.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is false and replay_hash is null." },
|
||||
[ReplayStatus.DIVERGED]: { label: "diverged", colorToken: "--color-review-rejected", meaning: "Replay completed but generated different state/output.", adr: "ADR-0160 / ADR-0162", evidence: "equivalent is false and replay_hash is present." },
|
||||
[ReplayStatus.EVIDENCE_UNAVAILABLE]: { label: "evidence_unavailable", colorToken: "--color-review-withdrawn", meaning: "Replay evidence is not available on the backend for this kind.", adr: "ADR-0160 / ADR-0162", evidence: "API returned unsupported error code." },
|
||||
} satisfies BadgeMeta<ReplayStatus>;
|
||||
|
|
|
|||
|
|
@ -39,19 +39,6 @@ export enum GroundingSource {
|
|||
NONE = "none",
|
||||
}
|
||||
|
||||
export enum ReplayDivergenceSeverity {
|
||||
INFO = "info",
|
||||
WARNING = "warning",
|
||||
FAILURE = "failure",
|
||||
}
|
||||
|
||||
export enum ReplayStatus {
|
||||
EQUIVALENT = "equivalent",
|
||||
NOT_YET_REPLAYED = "not_yet_replayed",
|
||||
DIVERGED = "diverged",
|
||||
EVIDENCE_UNAVAILABLE = "evidence_unavailable",
|
||||
}
|
||||
|
||||
export type BadgeMeta<T extends string> = Record<
|
||||
T,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -19,13 +19,10 @@ import { describe, expect, it } from "vitest";
|
|||
* only shrink.
|
||||
*/
|
||||
|
||||
const NOT_YET_MIRRORED = new Set([
|
||||
// Wave R3 sealed turn replay backend — TS mirrors land with the frontend
|
||||
// Replay Moment PR (which also retires the W-026 artifact-keyed
|
||||
// ReplayComparison/ReplayDivergence pair on both sides):
|
||||
"TurnReplayComparison",
|
||||
"TurnReplayDivergence",
|
||||
]);
|
||||
// Empty: every engine schema is now mirrored. New unmirrored schemas may be
|
||||
// allowlisted here as shrink-only debt (a class gaining a mirror while listed
|
||||
// fails the gate).
|
||||
const NOT_YET_MIRRORED = new Set<string>([]);
|
||||
|
||||
const UI_ROOT = join(__dirname, "..", "..", "..");
|
||||
const snapshot: Record<string, string[]> = JSON.parse(
|
||||
|
|
|
|||
|
|
@ -247,21 +247,28 @@ export interface EvalRunResult {
|
|||
source_digest: string | null;
|
||||
}
|
||||
|
||||
export type ReplayDivergenceSeverity = "info" | "warning" | "failure";
|
||||
// Wave R3 — sealed single-turn replay (turn-keyed; supersedes the W-026
|
||||
// artifact-keyed ReplayComparison, now retired on both sides).
|
||||
export type TurnReplayDivergenceSeverity = "critical" | "informational";
|
||||
export type TurnReplayBasis = "sealed_fresh_runtime_single_turn";
|
||||
export type TurnReplayOriginState = "unrecorded";
|
||||
|
||||
export interface ReplayDivergence {
|
||||
export interface TurnReplayDivergence {
|
||||
path: string;
|
||||
original: unknown;
|
||||
replay: unknown;
|
||||
severity: ReplayDivergenceSeverity;
|
||||
severity: TurnReplayDivergenceSeverity;
|
||||
}
|
||||
|
||||
export interface ReplayComparison {
|
||||
artifact_id: string;
|
||||
original_hash: string | null;
|
||||
replay_hash: string | null;
|
||||
export interface TurnReplayComparison {
|
||||
turn_id: number;
|
||||
comparison_basis: TurnReplayBasis;
|
||||
origin_state: TurnReplayOriginState;
|
||||
original_trace_hash: string | null;
|
||||
replay_trace_hash: string | null;
|
||||
equivalent: boolean;
|
||||
divergences: ReplayDivergence[];
|
||||
replay_turn_cost_ms: number;
|
||||
divergences: TurnReplayDivergence[];
|
||||
}
|
||||
|
||||
export interface VaultSummary {
|
||||
|
|
|
|||
|
|
@ -210,27 +210,6 @@ class EvalRunResult:
|
|||
source_digest: str | None = None
|
||||
|
||||
|
||||
ReplayDivergenceSeverity = Literal["info", "warning", "failure"]
|
||||
ReplayStatus = Literal["equivalent", "not_yet_replayed", "diverged", "evidence_unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayDivergence:
|
||||
path: str
|
||||
original: Any
|
||||
replay: Any
|
||||
severity: ReplayDivergenceSeverity
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReplayComparison:
|
||||
artifact_id: str
|
||||
original_hash: str | None
|
||||
replay_hash: str | None
|
||||
equivalent: bool
|
||||
divergences: list[ReplayDivergence] = field(default_factory=list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Wave R3 — sealed single-turn replay over the turn journal.
|
||||
# Scoping: docs/analysis/replay-moment-backend-scoping-2026-06-12.md.
|
||||
|
|
|
|||
Loading…
Reference in a new issue