feat: show schema approval in demo interrupt

This commit is contained in:
lda
2026-07-09 05:43:37 +07:00 Verified
parent a65da60a5c
commit 22386f7e2d
4 changed files with 53 additions and 6 deletions
@@ -1,4 +1,4 @@
import { cleanup, render, screen } from "@testing-library/react";
import { cleanup, render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
@@ -190,6 +190,26 @@ describe("DemoWorkflowScene", () => {
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("schema-backed");
});
it("shows a schema approval surface for the approval beat instead of raw schema as the primary visual", () => {
renderBeat("approval", "interrupt-evidence");
const approval = screen.getByRole("group", { name: /issue_review resume/i });
expect(within(approval).getByText("Schema-backed decision")).toBeInTheDocument();
expect(within(approval).getByText("selected_issue_ids")).toBeInTheDocument();
expect(within(approval).getByText("[\"risk-1\"]")).toBeInTheDocument();
expect(within(approval).getByRole("button", { name: /submit/i })).toBeDisabled();
expect(within(approval).getByRole("button", { name: /cancel/i })).toBeDisabled();
});
it("keeps raw resume schema visible only in interrupt preview mode", () => {
renderBeat("interrupt");
expect(screen.getByText("Resume schema")).toBeInTheDocument();
cleanup();
renderBeat("approval", "interrupt-evidence");
expect(screen.queryByText("Resume schema")).not.toBeInTheDocument();
});
it("adds outcome proof to approval, resume, output, and trace beats", () => {
const approval = renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("schema-backed");
@@ -54,9 +54,10 @@ export const DemoWorkflowScene = ({
openEvidence,
}: DemoWorkflowSceneProps) => {
const runStart = findEvent(demo, "run_start");
const runResume = findEvent(demo, "run_resume");
const currentStage = operationStageByBeat[beat.id];
const currentEvent = currentStage ? findEvent(demo, currentStage) : null;
const contract = runStart ? projectInterruptContract(runStart) : null;
const contract = runStart ? projectInterruptContract(runStart, runResume) : null;
const execution = graphExecutionForBeat(beat.id);
const layout = layoutForBeat(beat.id);
@@ -1,5 +1,6 @@
import { m } from "motion/react";
import type { InterruptContractPresentation } from "./demo-workflow-model.js";
import { SchemaApprovalSurface } from "./approval/SchemaApprovalSurface.js";
import { formatJson } from "./format.js";
type InterruptContractPreviewProps = {
@@ -36,9 +37,19 @@ export const InterruptContractPreview = ({
<dd>{contract.outcomes.join(" / ")}</dd>
</div>
</dl>
<div className="interrupt-contract-preview__schema">
<span>Resume schema</span>
<pre><code>{formatJson(contract.resumeSchema)}</code></pre>
</div>
{mode === "approval" ? (
<SchemaApprovalSurface
title={`${contract.kind} resume`}
schema={contract.resumeSchema}
payload={contract.resumePayloadPreview}
outcomes={contract.outcomes}
runId={contract.runId}
/>
) : (
<div className="interrupt-contract-preview__schema">
<span>Resume schema</span>
<pre><code>{formatJson(contract.resumeSchema)}</code></pre>
</div>
)}
</m.aside>
);
@@ -4,9 +4,15 @@ import type { DemoEvent } from "../demo/timeline/models.js";
const InterruptProjectionSchema = v.looseObject({
kind: v.string(),
outcomes: v.optional(v.array(v.string())),
request_schema: v.optional(v.unknown()),
resume_schema: v.optional(v.unknown()),
});
const ResumeParamsSchema = v.looseObject({
resume_payload: v.optional(v.unknown()),
resume_outcome: v.optional(v.string()),
});
const RunInterpretationSchema = v.looseObject({
status: v.optional(v.string()),
interrupt: v.optional(v.nullable(InterruptProjectionSchema)),
@@ -31,7 +37,10 @@ export type OperationPresentation = {
export type InterruptContractPresentation = {
readonly kind: string;
readonly outcomes: ReadonlyArray<string>;
readonly requestSchema: unknown;
readonly resumeSchema: unknown;
readonly resumePayloadPreview: unknown;
readonly resumeOutcome: string | null;
readonly runId: string | null;
};
@@ -148,13 +157,19 @@ export const projectOperationPresentation = (
export const projectInterruptContract = (
event: DemoEvent,
resumeEvent?: DemoEvent | null,
): InterruptContractPresentation | null => {
const interrupt = decodeInterpretation(event)?.interrupt;
if (!interrupt || !interrupt.outcomes || interrupt.resume_schema === undefined) return null;
const decodedResumeParams = v.safeParse(ResumeParamsSchema, resumeEvent?.params);
const resumeParams = decodedResumeParams.success ? decodedResumeParams.output : null;
return {
kind: interrupt.kind,
outcomes: interrupt.outcomes,
requestSchema: interrupt.request_schema ?? null,
resumeSchema: interrupt.resume_schema,
resumePayloadPreview: resumeParams?.resume_payload ?? null,
resumeOutcome: resumeParams?.resume_outcome ?? null,
runId: event.resultingIds.runId,
};
};