feat(runner): reuse issue thread viewer for evals

This commit is contained in:
Dotta committed 2026-08-20 14:49:58 -05:00
1 parent eac93e3af2
commit d8abf61570
6 files changed
+88 -7

No files matched your search

@@ -50,6 +50,25 @@ const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"];
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
interface EmbeddedEvalCheck {
id: string;
passed: boolean;
detail: string;
}
interface EmbeddedEvalReport {
attemptId: string;
caseId: string;
disposition: string;
passed: boolean;
checks: EmbeddedEvalCheck[];
view: CapabilityIssueThreadSnapshot;
}
declare global {
interface Window { __PAPERCLIP_EVAL_REPORT__?: EmbeddedEvalReport }
}
interface StoredChatSession {
sessionId: string;
snapshot: CapabilityIssueThreadSnapshot;
@@ -174,15 +193,20 @@ function useLayout(): "side" | "overlay" | "segment" {
}
export function App() {
const embeddedEval = window.__PAPERCLIP_EVAL_REPORT__ ?? null;
const route = useRoute();
const chat = route.surface === "chat";
const layout = useLayout();
useEffect(() => {
if (embeddedEval !== null) {
document.title = `${embeddedEval.passed ? "✓" : "✕"} ${embeddedEval.caseId} · paperclip-runner eval`;
return;
}
document.title = chat
? "🫧 Mock Paperclip · Issue thread"
: "🧯 Mock Paperclip · Issue thread";
}, [chat]);
}, [chat, embeddedEval]);
const [snapshot, setSnapshot] = useState<CapabilityIssueThreadSnapshot | null>(null);
const [devtools, setDevtools] = useState<CapabilityDevtoolsSnapshot | null>(null);
@@ -244,7 +268,14 @@ export function App() {
let cancelled = false;
setSettled(false);
setError(null);
if (chat) {
if (embeddedEval !== null) {
setIdentity(null);
setSnapshot({
...embeddedEval.view,
composer: { state: "disabled", helper: null, reason: "Immutable eval recording", pendingInteractionId: null },
});
setSnapshotSurface("issue");
} else if (chat) {
// The clean room has no fixture fallback: if real Codex and real runnerd
// cannot start, the surface says so rather than rendering a canned thread.
void (async () => {
@@ -283,7 +314,7 @@ export function App() {
// into a thread that is no longer on screen.
abandonTurn();
};
}, [abandonTurn, chat, route.mode, route.shot, route.fixtureProfile, loadNonce]);
}, [abandonTurn, chat, embeddedEval, route.mode, route.shot, route.fixtureProfile, loadNonce]);
/* ------------------------------------------------------- deep-link params */
@@ -794,10 +825,11 @@ export function App() {
data-surface={route.surface}
data-connection-state={snapshot.connection.state}
>
{surfaceNav}
{embeddedEval === null ? surfaceNav : null}
<IssueHeader
snapshot={snapshot}
readOnly={embeddedEval !== null}
scenarios={SCENARIOS}
surface={route.surface}
cleanRoomToken={identity?.token ?? null}
@@ -825,6 +857,23 @@ export function App() {
onSelectSegment={setSegment}
/>
{embeddedEval !== null ? (
<section className="pit-eval-summary" data-passed={embeddedEval.passed} data-testid="eval-summary">
<div className="pit-eval-summary-head">
<a href="../../index.html">← Eval suite</a>
<strong>{embeddedEval.passed ? "PASS" : embeddedEval.disposition.replaceAll("_", " ").toUpperCase()}</strong>
<code>{embeddedEval.attemptId}</code>
</div>
<div className="pit-eval-checks">
{embeddedEval.checks.map((check) => (
<span key={check.id} data-passed={check.passed} title={check.detail}>
{check.passed ? "✓" : "✕"} {check.id}
</span>
))}
</div>
</section>
) : null}
{actionError !== null ? (
<p className="pit-banner" data-tone="danger" role="alert" data-testid="action-error">
<span aria-hidden="true">⚠</span>
@@ -15,6 +15,8 @@ export interface IssueHeaderProps {
scenarios: string[];
/** `chat` is the clean room: no preset scenario, no recording to replay. */
surface?: "issue" | "chat";
/** Immutable eval recordings use the same inspector without live controls. */
readOnly?: boolean;
/** Clean-room tenant token, rendered so identity rotation is visible. */
cleanRoomToken?: string | null;
evidenceOpen: boolean;
@@ -33,6 +35,7 @@ export function IssueHeader(props: IssueHeaderProps) {
snapshot,
scenarios,
surface = "issue",
readOnly = false,
cleanRoomToken = null,
evidenceOpen,
denialCount,
@@ -67,7 +70,7 @@ export function IssueHeader(props: IssueHeaderProps) {
<StatusBadge status={snapshot.issue.status} />
</div>
<div className="pit-header-controls">
{!chat ? (
{!chat && !readOnly ? (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">Scenario</label>
<select className="pit-select pit-desktop-only" id="scenario-picker" value={snapshot.issue.fixtureProfile} onChange={(event) => onSelectScenario(event.target.value)} data-testid="scenario-picker">
@@ -76,8 +79,8 @@ export function IssueHeader(props: IssueHeaderProps) {
<button type="button" className="pit-button pit-desktop-only" onClick={onReplay} data-testid="replay-button"><Icon name="play" /> Replay</button>
</>
) : null}
<button type="button" className="pit-icon-button pit-desktop-only" data-variant="destructive" onClick={onReset} data-testid="reset-button" title={chat ? "Reset chat" : "Reset scenario"} aria-label={chat ? "Reset chat" : "Reset scenario"}><Icon name="reset" /></button>
<button type="button" className="pit-icon-button" data-variant="destructive" onClick={onStop} disabled={!turnActive} data-testid="stop-button" title="Stop turn" aria-label="Stop turn"><Icon name="stop" /></button>
{!readOnly ? <button type="button" className="pit-icon-button pit-desktop-only" data-variant="destructive" onClick={onReset} data-testid="reset-button" title={chat ? "Reset chat" : "Reset scenario"} aria-label={chat ? "Reset chat" : "Reset scenario"}><Icon name="reset" /></button> : null}
{!readOnly ? <button type="button" className="pit-icon-button" data-variant="destructive" onClick={onStop} disabled={!turnActive} data-testid="stop-button" title="Stop turn" aria-label="Stop turn"><Icon name="stop" /></button> : null}
<button type="button" className="pit-button pit-desktop-only" aria-expanded={evidenceOpen} onClick={onToggleEvidence} data-testid="evidence-toggle"><Icon name="evidence" /> DevTools{denialCount > 0 ? ` (${denialCount})` : ""}</button>
</div>
</div>
@@ -1229,6 +1229,31 @@ textarea {
border-bottom-color: var(--pit-danger);
}
.pit-eval-summary {
border-block: 1px solid color-mix(in oklch, var(--pit-success) 45%, var(--pit-border));
background: color-mix(in oklch, var(--pit-success) 10%, var(--pit-surface-sunken));
padding: 10px 20px;
}
.pit-eval-summary[data-passed="false"] {
border-color: color-mix(in oklch, var(--pit-danger) 45%, var(--pit-border));
background: color-mix(in oklch, var(--pit-danger) 10%, var(--pit-surface-sunken));
}
.pit-eval-summary-head,
.pit-eval-checks {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 14px;
}
.pit-eval-summary-head code { color: var(--pit-muted); }
.pit-eval-checks { margin-top: 8px; }
.pit-eval-checks span { padding: 3px 8px; border-radius: 999px; background: var(--pit-surface-raised); }
.pit-eval-checks span[data-passed="true"] { color: var(--pit-success); }
.pit-eval-checks span[data-passed="false"] { color: var(--pit-danger); }
@keyframes pit-slide {
from {
transform: translateY(-0.5rem);
+1
View File
@@ -44,6 +44,7 @@
},
"files": [
"dist",
"dist-issue-thread",
"protocol",
"styles.css",
"README.md"
@@ -5,6 +5,7 @@ import { resolve } from "node:path";
import { projectCapabilityDevtools } from "../devtools/index.js";
import { PAPERCLIP_RUNNER_BUILD_METADATA } from "../evals/build-metadata.js";
import { projectCapabilityIssueThread } from "../issue-thread/live-projection.js";
import {
CapabilityLiveSessionService,
reconcileCapabilityLiveUsage,
@@ -68,6 +69,7 @@ try {
turn,
snapshot,
devtools: projectCapabilityDevtools(snapshot),
issueThread: projectCapabilityIssueThread({ snapshot, mode: "replay", replaySource: "live" }),
usage,
}, null, 2)}\n`);
} catch (error) {
@@ -9,6 +9,7 @@ import { capabilityIssueThreadServerPlugin } from "./scripts/capability-issue-th
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
base: "./",
root: resolve(packageRoot, "devtools/issue-thread"),
plugins: [react(), capabilityIssueThreadServerPlugin()],
server: { host: "127.0.0.1" },