diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml
index 59f1627c0a..3ff2e2def9 100644
--- a/.github/workflows/e2e.yml
+++ b/.github/workflows/e2e.yml
@@ -124,6 +124,7 @@ jobs:
- name: Upload Playwright Blob Report
uses: actions/upload-artifact@v4
if: always()
+ continue-on-error: true
with:
name: playwright-blob-${{ matrix.shard }}
path: blob-report/
diff --git a/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx b/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx
index 4b7c3dc4c0..846392414f 100644
--- a/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx
+++ b/src/__tests__/unit/components/BenchmarkRunsHistory.test.tsx
@@ -29,6 +29,7 @@ const makeRun = (overrides: Partial<{
generateJamieChat: boolean;
jamieChatStatus: string;
jamieChatPath: string;
+ generateRunReport: boolean;
hasReport: boolean;
}> = {}) => ({
id: "runner-1",
@@ -48,6 +49,7 @@ const makeRun = (overrides: Partial<{
generateJamieChat: undefined as boolean | undefined,
jamieChatStatus: undefined as string | undefined,
jamieChatPath: undefined as string | undefined,
+ generateRunReport: undefined as boolean | undefined,
hasReport: undefined as boolean | undefined,
...overrides,
});
@@ -262,13 +264,29 @@ describe("BenchmarkRunsHistory", () => {
);
});
- it("renders Runner Status column header and Pass/Total column headers (no Fail header)", () => {
+ it("renders Runner Status column header and Pass/Fail/Total column headers", () => {
render(React.createElement(BenchmarkRunsHistory));
expect(screen.getByText("Runner Status")).toBeInTheDocument();
expect(screen.getByText("Pass")).toBeInTheDocument();
+ expect(screen.getByText("Fail")).toBeInTheDocument();
expect(screen.getByText("Total")).toBeInTheDocument();
expect(screen.queryByText("Score")).toBeNull();
- expect(screen.queryByText("Fail")).toBeNull();
+ });
+
+ it("renders the score column headers in order Pass, Fail, Contested, Disputed, Total", () => {
+ render(React.createElement(BenchmarkRunsHistory));
+ const headers = Array.from(document.querySelectorAll("thead th")).map(
+ (th) => th.textContent?.trim(),
+ );
+ const scoreHeaders = headers.filter((h) =>
+ ["Pass", "Fail", "Contested", "Disputed", "Total"].includes(h ?? ""),
+ );
+ expect(scoreHeaders).toEqual(["Pass", "Fail", "Contested", "Disputed", "Total"]);
+ });
+
+ it("does NOT render a Chat column header", () => {
+ render(React.createElement(BenchmarkRunsHistory));
+ expect(screen.queryByText("Chat")).toBeNull();
});
it("shows COMPLETED badge for a completed run", () => {
@@ -466,7 +484,7 @@ describe("BenchmarkRunsHistory", () => {
// ─── colSpan tests ─────────────────────────────────────────────────────────
- it("expanded row colSpan is 9 for non-super-admin (Task + Type + Started + Runner Status + Score + Contested + Disputed + Chat + Report)", async () => {
+ it("expanded row colSpan is 10 for non-super-admin (Task + Type + Started + Runner Status + Pass + Fail + Contested + Disputed + Total + Report)", async () => {
const user = userEvent.setup();
render(React.createElement(BenchmarkRunsHistory));
@@ -779,14 +797,11 @@ describe("BenchmarkRunsHistory", () => {
expect(expandedCell.getAttribute("colspan")).toBe("10");
});
- // ─── Chat column tests ─────────────────────────────────────────────────────
-
- it("renders Chat column header", () => {
- render(React.createElement(BenchmarkRunsHistory));
- expect(screen.getByText("Chat")).toBeInTheDocument();
- });
+ // ─── Chat column removal ───────────────────────────────────────────────────
+ // The Chat column/link were removed from this table (Jamie chat data and
+ // generation are unaffected — see useLegalBenchmarkRunList.ts).
- it("renders icon-only 'View Chat' link when jamieChatPath is present", () => {
+ it("does NOT render the report-chat-link even when jamieChatPath is present", () => {
mockUseList.mockReturnValue({
runs: [makeRun({
generateJamieChat: true,
@@ -800,13 +815,7 @@ describe("BenchmarkRunsHistory", () => {
setExpandedId: mockSetExpandedId,
});
render(React.createElement(BenchmarkRunsHistory));
- const link = screen.getByTestId("report-chat-link");
- expect(link).toBeInTheDocument();
- expect(link.getAttribute("href")).toBe("/org/stakwork?chat=conv-123");
- expect(link.getAttribute("target")).toBe("_blank");
- expect(link.getAttribute("title")).toBe("View Chat");
- expect(link.getAttribute("aria-label")).toBe("View Chat");
- expect(link.textContent).not.toBe("View Chat");
+ expect(screen.queryByTestId("report-chat-link")).toBeNull();
});
it("renders icon-only 'View Report' link with correct attributes when hasReport is true", () => {
@@ -850,8 +859,10 @@ describe("BenchmarkRunsHistory", () => {
});
it("shows Pending spinner when report requested but not yet written", () => {
+ // Keyed off generateRunReport (ReportCell's own field) — generateJamieChat
+ // no longer drives any visible UI in this table now that Chat is removed.
mockUseList.mockReturnValue({
- runs: [makeRun({ status: "IN_PROGRESS", generateJamieChat: true })],
+ runs: [makeRun({ status: "IN_PROGRESS", generateRunReport: true })],
total: 1,
isLoading: false,
error: null,
@@ -863,23 +874,6 @@ describe("BenchmarkRunsHistory", () => {
expect(screen.queryByTestId("report-chat-link")).toBeNull();
});
- it("shows 'Failed' when jamieChatStatus is failed", () => {
- mockUseList.mockReturnValue({
- runs: [makeRun({ generateJamieChat: true, jamieChatStatus: "failed" })],
- total: 1,
- isLoading: false,
- error: null,
- refetch: mockRefetch,
- setExpandedId: mockSetExpandedId,
- });
- render(React.createElement(BenchmarkRunsHistory));
- const failedMatches = screen.getAllByText("Failed");
- expect(failedMatches.length).toBeGreaterThan(0);
- expect(
- failedMatches.some((el) => el.className.includes("text-destructive")),
- ).toBe(true);
- });
-
it("shows dash (not Pending) for a FAILED run with generateJamieChat (report will never fire)", () => {
mockUseList.mockReturnValue({
runs: [makeRun({ status: "FAILED", generateJamieChat: true })],
@@ -1552,6 +1546,100 @@ describe("BenchmarkRunsHistory — graph-first score numerators", () => {
expect(row.textContent).toContain("—");
expect(row.textContent).not.toContain("8/");
});
+
+ // ─── Fail column ───────────────────────────────────────────────────────────
+
+ it("renders the computed Fail count (fail-cell-count) when a rubric breakdown is computed", () => {
+ // roster has 10 entries, 2 contested → scorable 8; n_passed 8 → fail 0.
+ // Use 6 passed instead so fail is a clearly non-zero, non-clamped number.
+ mockUseList.mockReturnValue({
+ runs: [makeRun({ id: "m-1", taskSlug: TASK, status: "COMPLETED", n_passed: 6, n_total: 10, all_pass: false })],
+ total: 1,
+ isLoading: false,
+ error: null,
+ refetch: mockRefetch,
+ setExpandedId: mockSetExpandedId,
+ });
+ mockRubricsMapHook.mockImplementation(() => new Map([[TASK, roster]]));
+
+ render();
+
+ const row = screen.getByTestId("run-row-m-1");
+ const failCell = row.querySelector('[data-testid="fail-cell-count"]');
+ expect(failCell).toBeInTheDocument();
+ // scorable = 10 - 2 contested = 8; pass = 6; fail = 8 - 6 = 2.
+ expect(failCell?.textContent).toBe("2");
+ });
+
+ it("renders n/a (fail-cell-unknown), never 0, for an output-ref pointer row (n_failed === null)", () => {
+ const manualWithPointer = {
+ ...makeRun({ id: "m-1", taskSlug: TASK, status: "COMPLETED", n_passed: 50, n_total: 74, all_pass: false }),
+ evalTriggerRef: "trig-1",
+ evalOutputRef: "out-9",
+ };
+ mockUseList.mockReturnValue({
+ runs: [manualWithPointer],
+ total: 1,
+ isLoading: false,
+ error: null,
+ refetch: mockRefetch,
+ setExpandedId: mockSetExpandedId,
+ });
+ mockRubricsMapHook.mockImplementation(() => new Map([[TASK, roster]]));
+ mockGraphScoresMapHook.mockImplementation(
+ () =>
+ new Map([
+ [TASK, [graphOutput({ ref_id: "out-9", triggerRef: undefined, n_passed: 9, n_total: 10 })]],
+ ]),
+ );
+
+ render();
+
+ const row = screen.getByTestId("run-row-m-1");
+ const unknownCell = row.querySelector('[data-testid="fail-cell-unknown"]');
+ expect(unknownCell).toBeInTheDocument();
+ expect(unknownCell?.textContent).toBe("n/a");
+ expect(row.querySelector('[data-testid="fail-cell-count"]')).toBeNull();
+ });
+
+ it("renders the muted dash for Fail when the row has no score data (in-progress run)", () => {
+ mockUseList.mockReturnValue({
+ runs: [makeRun({ id: "m-1", taskSlug: TASK, status: "IN_PROGRESS" })],
+ total: 1,
+ isLoading: false,
+ error: null,
+ refetch: mockRefetch,
+ setExpandedId: mockSetExpandedId,
+ });
+ mockRubricsMapHook.mockImplementation(() => new Map([[TASK, roster]]));
+
+ render();
+
+ const row = screen.getByTestId("run-row-m-1");
+ expect(row.querySelector('[data-testid="fail-cell-count"]')).toBeNull();
+ expect(row.querySelector('[data-testid="fail-cell-unknown"]')).toBeNull();
+ });
+
+ it("renders 0 (never a negative number) for Fail on a clamped row (pass exceeds scorable)", () => {
+ // n_passed (10) exceeds scorable (10 - 2 contested = 8) — rubricBreakdown
+ // clamps pass to 8, so fail = 8 - 8 = 0, never negative.
+ mockUseList.mockReturnValue({
+ runs: [makeRun({ id: "m-1", taskSlug: TASK, status: "COMPLETED", n_passed: 10, n_total: 10, all_pass: true })],
+ total: 1,
+ isLoading: false,
+ error: null,
+ refetch: mockRefetch,
+ setExpandedId: mockSetExpandedId,
+ });
+ mockRubricsMapHook.mockImplementation(() => new Map([[TASK, roster]]));
+
+ render();
+
+ const row = screen.getByTestId("run-row-m-1");
+ const failCell = row.querySelector('[data-testid="fail-cell-count"]');
+ expect(failCell).toBeInTheDocument();
+ expect(failCell?.textContent).toBe("0");
+ });
});
// ─── Non-manual (recursion) row expand tests ──────────────────────────────────
diff --git a/src/components/legal/BenchmarkRunsHistory.tsx b/src/components/legal/BenchmarkRunsHistory.tsx
index 2fe623b679..62f0bf833d 100644
--- a/src/components/legal/BenchmarkRunsHistory.tsx
+++ b/src/components/legal/BenchmarkRunsHistory.tsx
@@ -530,8 +530,8 @@ export function BenchmarkRunsHistory({
);
}
- // colSpan: Task + Type + Started + Runner Status + Pass + Total + Contested +
- // Disputed + Chat + Report + (Stakwork if super admin)
+ // colSpan: Task + Type + Started + Runner Status + Pass + Fail + Contested +
+ // Disputed + Total + Report + (Stakwork if super admin)
const colSpan = isSuperAdmin ? 11 : 10;
return (
@@ -609,7 +609,14 @@ export function BenchmarkRunsHistory({
Started |
Runner Status |
Pass |
- Total |
+
+
+ Fail
+
+ |
|
- Chat |
+ Total |
Report |
{isSuperAdmin && (
Stakwork |
@@ -702,7 +709,7 @@ export function BenchmarkRunsHistory({
-
+
|
@@ -710,12 +717,8 @@ export function BenchmarkRunsHistory({
|
|
- e.stopPropagation()}>
- {run.runType === "manual" ? (
-
- ) : (
- —
- )}
+ |
+
|
e.stopPropagation()}>
{/* Report bundles land on recursion rows too (reportUrl
@@ -809,9 +812,10 @@ function TaskProgressCard({
/**
* The run report bundle — the nine-section report built from the Harvey
- * runner's S3 output. A DIFFERENT artifact from the Jamie chat next door: this
- * one is produced by the runner itself and rendered natively by Hive, while the
- * chat is an org-canvas conversation written afterwards by the canvas agent.
+ * runner's S3 output. Produced by the runner itself and rendered natively by
+ * Hive. (The Jamie chat — an org-canvas conversation written afterwards by
+ * the canvas agent — is a separate artifact; its data is still generated and
+ * fetched, but this table no longer renders a column for it.)
*
* `hasReport` is derived server-side from the presence of the persisted
* projection — never from the bundle URL, which does not reach this component.
@@ -848,46 +852,6 @@ function ReportCell({ run, slug }: { run: BenchmarkRunListRow; slug?: string })
return —;
}
-/**
- * The Jamie chat produced by `generateBenchmarkJamieChat` — an org-canvas
- * conversation. Distinct from the run report bundle, which has its own column.
- */
-function ChatCell({ run }: { run: BenchmarkRunListRow }) {
- if (run.jamieChatPath) {
- return (
-
-
-
- );
- }
-
- if (run.jamieChatStatus === "failed") {
- return Failed;
- }
-
- // Requested but not yet started/written (run still executing, or the
- // completion webhook is generating the chat right now). A FAILED run
- // never triggers a chat, so fall through to the dash instead.
- if (run.generateJamieChat && run.status !== WorkflowStatus.FAILED) {
- return (
-
-
- Pending
-
- );
- }
-
- return —;
-}
-
function hasScoreData(run: AdjustedRun): boolean {
const isActive =
run.status === WorkflowStatus.PENDING || run.status === WorkflowStatus.IN_PROGRESS;
@@ -938,6 +902,46 @@ function PassCell({ run }: { run: AdjustedRun }) {
);
}
+/**
+ * Failed-criteria count, threaded straight from `AdjustedRun.n_failed`
+ * (computed once in `rubricBreakdown`, never recomputed here). Two accepted
+ * divergences from neighbouring cells, left as-is deliberately:
+ * (a) The visible columns need not sum to Total. `n_failed` is
+ * `scorable − pass` against a TRUE UNION of contested criteria, while
+ * `ContestedCountCell` renders `score.contested` =
+ * `Math.max(rosterContested, contestedInRun)` — a strictly smaller set
+ * on some rows (rubric-scoring.ts). Pass + Fail + Contested can land
+ * short of Total.
+ * (b) On rows where `rubricBreakdown` clamps `pass` to `scorable`
+ * (rubric-scoring.ts), Fail shows `0` while `PassCell` renders the
+ * *unclamped* `run.n_passed` — so Pass can visibly exceed
+ * `Total − Contested`. `PassCell` is intentionally left alone here;
+ * changing what Pass renders would alter displayed scores, which is out
+ * of scope. Reconciling the two contested sets is a follow-up in
+ * rubric-scoring.ts, not part of this cell.
+ */
+function FailCell({ run }: { run: AdjustedRun }) {
+ if (!hasScoreData(run)) {
+ return —;
+ }
+ if (typeof run.n_failed !== "number") {
+ return (
+
+ n/a
+
+ );
+ }
+ return (
+
+ {run.n_failed}
+
+ );
+}
+
/**
* The full graph rubric roster size. Independent of `hasScoreData`/`all_pass`
* — an in-progress run with an already-loaded roster still shows its Total
diff --git a/src/hooks/useLegalBenchmarkRunList.ts b/src/hooks/useLegalBenchmarkRunList.ts
index 93b941afe7..67f7a2a695 100644
--- a/src/hooks/useLegalBenchmarkRunList.ts
+++ b/src/hooks/useLegalBenchmarkRunList.ts
@@ -61,6 +61,9 @@ export interface BenchmarkRunListRow {
requestedModel?: string;
/** Operator-chosen judge model (bare name). Absent on legacy runs. */
requestedJudgeModel?: string;
+ // The Legal Benchmark runs table no longer renders a Chat column — these
+ // fields are parsed and retained deliberately (chat data/generation still
+ // happen), not dead code.
/** Operator checked "Jamie Chat" at run creation (legacy key name) */
generateJamieChat?: boolean;
/** Jamie chat lifecycle: "generating" | "completed" | "failed" */
|