fix: clarify schema approval presentation

This commit is contained in:
lda
2026-07-09 06:00:05 +07:00 Verified
parent 28a720b998
commit a86f54d14a
14 changed files with 47 additions and 39 deletions
@@ -95,7 +95,7 @@ export async function* runPreparedRecipeReplay(
: undefined; : undefined;
const approvalParts: AgentMessagePart[] = [ const approvalParts: AgentMessagePart[] = [
agentToolCallPart(callId, step.toolName, { runId }), agentToolCallPart(callId, step.toolName, { runId }),
approvalRequestPart(callId, step.toolName, "Approve resuming the workflow run with the selected issues?", approvalContract), approvalRequestPart(callId, step.toolName, "Submit the resume request with the selected issues?", approvalContract),
]; ];
yield { id: step.id, role: "assistant", parts: approvalParts }; yield { id: step.id, role: "assistant", parts: approvalParts };
const decision = await requestApproval(signal); const decision = await requestApproval(signal);
@@ -55,7 +55,7 @@ export const DemoOutcomePanel = ({
<strong>Product state, not chat-only text</strong> <strong>Product state, not chat-only text</strong>
<ul> <ul>
<li><b>Report markdown</b><small>Generated from selected documents.</small></li> <li><b>Report markdown</b><small>Generated from selected documents.</small></li>
<li><b>Issue board changes</b><small>Created only after approval.</small></li> <li><b>Issue board changes</b><small>Created only after resume submission.</small></li>
</ul> </ul>
</aside> </aside>
); );
@@ -112,7 +112,7 @@ describe("DemoWorkflowScene", () => {
expect(stage).toHaveAttribute("data-demo-layout", "approval"); expect(stage).toHaveAttribute("data-demo-layout", "approval");
expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true"); expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true");
expect(screen.getByLabelText("typed interrupt contract")).toHaveTextContent("Operator decision"); expect(screen.getByLabelText("typed interrupt contract")).toHaveTextContent("Operator decision");
expect(screen.getByLabelText("typed interrupt contract")).toHaveTextContent("Resume outcomes"); expect(screen.getByRole("group", { name: /issue review resume/i })).toHaveTextContent("submitted / cancelled");
expect(screen.getByLabelText("workflow graph")).toBeInTheDocument(); expect(screen.getByLabelText("workflow graph")).toBeInTheDocument();
}); });
@@ -143,11 +143,11 @@ describe("DemoWorkflowScene", () => {
}); });
it("marks outcome-panel layouts so CSS can clear the receipt row", () => { it("marks outcome-panel layouts so CSS can clear the receipt row", () => {
renderBeat("approval", "interrupt-evidence"); renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "approval"); expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "operation");
expect(screen.getByLabelText("demo outcome proof")).toBeInTheDocument(); expect(screen.getByLabelText("demo outcome proof")).toBeInTheDocument();
expect(screen.getByLabelText("workflow.runs.start execution receipt")).toBeInTheDocument(); expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
}); });
it("shows the continuity rail across Scene 9 operation, graph, and interrupt beats", () => { it("shows the continuity rail across Scene 9 operation, graph, and interrupt beats", () => {
@@ -187,16 +187,17 @@ describe("DemoWorkflowScene", () => {
expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true"); expect(screen.getByLabelText("typed interrupt contract")).toHaveAttribute("data-hero", "true");
expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "compact"); expect(screen.getByLabelText("workflow graph")).toHaveAttribute("data-graph-variant", "compact");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("schema-backed"); expect(screen.queryByLabelText("demo outcome proof")).not.toBeInTheDocument();
}); });
it("shows a schema approval surface for the approval beat instead of raw schema as the primary visual", () => { it("shows a schema approval surface for the approval beat instead of raw schema as the primary visual", () => {
renderBeat("approval", "interrupt-evidence"); renderBeat("approval", "interrupt-evidence");
const approval = screen.getByRole("group", { name: /issue_review resume/i }); const approval = screen.getByRole("group", { name: /issue review resume/i });
expect(within(approval).getByText("Schema-backed decision")).toBeInTheDocument(); expect(within(approval).getByText("Schema-backed decision")).toBeInTheDocument();
expect(within(approval).getByText("selected_issue_ids")).toBeInTheDocument(); expect(within(approval).getByText("selected_issue_ids")).toBeInTheDocument();
expect(within(approval).getByText("[\"risk-1\"]")).toBeInTheDocument(); expect(within(approval).getByText("[\"risk-1\"]")).toBeInTheDocument();
expect(within(approval).queryByText("approved")).not.toBeInTheDocument();
expect(within(approval).getByRole("button", { name: /submit/i })).toBeDisabled(); expect(within(approval).getByRole("button", { name: /submit/i })).toBeDisabled();
expect(within(approval).getByRole("button", { name: /cancel/i })).toBeDisabled(); expect(within(approval).getByRole("button", { name: /cancel/i })).toBeDisabled();
}); });
@@ -210,11 +211,7 @@ describe("DemoWorkflowScene", () => {
expect(screen.queryByText("Resume schema")).not.toBeInTheDocument(); expect(screen.queryByText("Resume schema")).not.toBeInTheDocument();
}); });
it("adds outcome proof to approval, resume, output, and trace beats", () => { it("adds outcome proof to resume, output, and trace beats", () => {
const approval = renderBeat("approval", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("schema-backed");
approval.unmount();
const resume = renderBeat("resume", "interrupt-evidence"); const resume = renderBeat("resume", "interrupt-evidence");
expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("Same persisted run"); expect(screen.getByLabelText("demo outcome proof")).toHaveTextContent("Same persisted run");
resume.unmount(); resume.unmount();
@@ -73,7 +73,7 @@ export const DemoWorkflowScene = ({
const lens = demoBeatLensForBeat(beat.id); const lens = demoBeatLensForBeat(beat.id);
const currentOperation = currentEvent ? projectOperationPresentation(currentEvent) : null; const currentOperation = currentEvent ? projectOperationPresentation(currentEvent) : null;
const showOutcomePanel = beat.id === "approval" || beat.id === "resume" || beat.id === "output" || beat.id === "trace"; const showOutcomePanel = beat.id === "resume" || beat.id === "output" || beat.id === "trace";
const showExpandedOperation = beat.id === "operation" || beat.id === "resume" || beat.id === "trace"; const showExpandedOperation = beat.id === "operation" || beat.id === "resume" || beat.id === "trace";
const showGraph = beat.id === "graph" || beat.id === "interrupt" || beat.id === "approval" || beat.id === "output"; const showGraph = beat.id === "graph" || beat.id === "interrupt" || beat.id === "approval" || beat.id === "output";
@@ -9,6 +9,8 @@ type InterruptContractPreviewProps = {
readonly hero?: boolean; readonly hero?: boolean;
}; };
const titleForKind = (kind: string): string => `${kind.replaceAll("_", " ")} resume`;
export const InterruptContractPreview = ({ export const InterruptContractPreview = ({
contract, contract,
mode, mode,
@@ -27,6 +29,7 @@ export const InterruptContractPreview = ({
<span>{mode === "approval" ? "Operator decision" : "Execution paused"}</span> <span>{mode === "approval" ? "Operator decision" : "Execution paused"}</span>
<strong>{contract.kind}</strong> <strong>{contract.kind}</strong>
</header> </header>
{mode === "preview" ? (
<dl> <dl>
<div> <div>
<dt>Persisted run</dt> <dt>Persisted run</dt>
@@ -37,9 +40,10 @@ export const InterruptContractPreview = ({
<dd>{contract.outcomes.join(" / ")}</dd> <dd>{contract.outcomes.join(" / ")}</dd>
</div> </div>
</dl> </dl>
) : null}
{mode === "approval" ? ( {mode === "approval" ? (
<SchemaApprovalSurface <SchemaApprovalSurface
title={`${contract.kind} resume`} title={titleForKind(contract.kind)}
schema={contract.resumeSchema} schema={contract.resumeSchema}
payload={contract.resumePayloadPreview} payload={contract.resumePayloadPreview}
outcomes={contract.outcomes} outcomes={contract.outcomes}
@@ -87,7 +87,7 @@ describe("OperatorChat", () => {
type: "approval-request", type: "approval-request",
callId: "call-1", callId: "call-1",
name: "resumeIssueReview", name: "resumeIssueReview",
prompt: "Approve resuming?", prompt: "Submit resume request?",
contract: { contract: {
kind: "issue_review", kind: "issue_review",
outcomes: ["submitted", "cancelled"], outcomes: ["submitted", "cancelled"],
@@ -102,7 +102,7 @@ describe("OperatorChat", () => {
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />); render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />);
expect(screen.getByRole("group", { name: /issue_review resume/i })).toBeInTheDocument(); expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /submit/i })); await user.click(screen.getByRole("button", { name: /submit/i }));
await user.click(screen.getByRole("button", { name: /cancel/i })); await user.click(screen.getByRole("button", { name: /cancel/i }));
@@ -123,7 +123,7 @@ describe("OperatorChat", () => {
type: "approval-request", type: "approval-request",
callId: "call-1", callId: "call-1",
name: "resumeIssueReview", name: "resumeIssueReview",
prompt: "Approve resuming?", prompt: "Submit resume request?",
}, },
], ],
}, },
@@ -131,7 +131,7 @@ describe("OperatorChat", () => {
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />); render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />);
expect(screen.queryByRole("group", { name: /issue_review resume/i })).not.toBeInTheDocument(); expect(screen.queryByRole("group", { name: /issue review resume/i })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Approve" })); await user.click(screen.getByRole("button", { name: "Approve" }));
await user.click(screen.getByRole("button", { name: "Deny" })); await user.click(screen.getByRole("button", { name: "Deny" }));
expect(onApprove).toHaveBeenCalledTimes(1); expect(onApprove).toHaveBeenCalledTimes(1);
@@ -81,7 +81,7 @@ const renderPart = (
<p>{part.prompt}</p> <p>{part.prompt}</p>
{part.contract ? ( {part.contract ? (
<SchemaApprovalSurface <SchemaApprovalSurface
title={`${part.contract.kind} resume`} title={`${part.contract.kind.replaceAll("_", " ")} resume`}
schema={part.contract.resumeSchema} schema={part.contract.resumeSchema}
payload={part.contract.resumePayloadPreview} payload={part.contract.resumePayloadPreview}
outcomes={part.contract.outcomes} outcomes={part.contract.outcomes}
@@ -51,7 +51,7 @@ describe("SchemaApprovalSurface", () => {
/>, />,
); );
expect(screen.getByText("No additional resume fields are declared by this schema.")).toBeInTheDocument(); expect(screen.getByText("Recorded resume payload for this decision.")).toBeInTheDocument();
expect(screen.getByText("selected_issue_ids")).toBeInTheDocument(); expect(screen.getByText("selected_issue_ids")).toBeInTheDocument();
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument(); expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
}); });
@@ -50,7 +50,7 @@ export const SchemaApprovalSurface = ({
</dl> </dl>
) : ( ) : (
<div className="schema-approval-surface__loose"> <div className="schema-approval-surface__loose">
<p>No additional resume fields are declared by this schema.</p> <p>Recorded resume payload for this decision.</p>
<dl> <dl>
{model.payloadPreview.map((entry) => ( {model.payloadPreview.map((entry) => (
<div key={entry.key}> <div key={entry.key}>
@@ -59,6 +59,7 @@ describe("buildSchemaApprovalModel", () => {
const model = buildSchemaApprovalModel({ const model = buildSchemaApprovalModel({
schema: { type: "object" }, schema: { type: "object" },
payload: { payload: {
approved: true,
selected_issue_ids: ["risk-1"], selected_issue_ids: ["risk-1"],
comment: "Create the selected issue.", comment: "Create the selected issue.",
}, },
@@ -51,7 +51,13 @@ const labelFor = (name: string): string => name.replaceAll("_", " ");
const payloadEntries = (payload: unknown): ReadonlyArray<{ readonly key: string; readonly value: string }> => { const payloadEntries = (payload: unknown): ReadonlyArray<{ readonly key: string; readonly value: string }> => {
if (!isObject(payload)) return []; if (!isObject(payload)) return [];
return Object.entries(payload).map(([key, value]) => ({ return Object.entries(payload)
// Loose resume schemas often carry implementation flags such as
// `approved: true`; the product surface should show the workflow outcome
// (`submitted/cancelled`) and the operator payload, not duplicate flag
// vocabulary that reads like a second outcome system.
.filter(([key]) => key !== "approved")
.map(([key, value]) => ({
key, key,
value: formatValue(value) ?? "undefined", value: formatValue(value) ?? "undefined",
})); }));
@@ -91,7 +91,7 @@ const demoBeatLensByBeat: Readonly<Record<string, DemoBeatLens>> = {
resume: { resume: {
phase: "resume", phase: "resume",
eyebrow: "Same run resumes", eyebrow: "Same run resumes",
headline: "The approved payload continues the persisted run", headline: "The submitted payload continues the persisted run",
proofLabel: "workflow.runs.resume", proofLabel: "workflow.runs.resume",
speakerLine: "Resume is not a restart; it continues the stopped run with a validated payload.", speakerLine: "Resume is not a restart; it continues the stopped run with a validated payload.",
}, },
@@ -196,7 +196,7 @@ export const mainScenes = defineScenes([
view: "demo", view: "demo",
beats: [ beats: [
sceneBeat("approval", "Approval", "The operator reviews a schema-backed resume request.", { chatMode: "hidden", chatTheme: "light" }), sceneBeat("approval", "Approval", "The operator reviews a schema-backed resume request.", { chatMode: "hidden", chatTheme: "light" }),
sceneBeat("resume", "Resume", "The approved payload resumes the same persisted run.", { chatMode: "hidden", chatTheme: "light" }), sceneBeat("resume", "Resume", "The submitted payload resumes the same persisted run.", { chatMode: "hidden", chatTheme: "light" }),
sceneBeat("output", "Output", "The workflow produces the report and issue-board changes.", { chatMode: "hidden", chatTheme: "light" }), sceneBeat("output", "Output", "The workflow produces the report and issue-board changes.", { chatMode: "hidden", chatTheme: "light" }),
sceneBeat("trace", "Evidence", "Trace frames and protocol evidence remain inspectable.", { chatMode: "dock", chatTheme: "light", evidencePresentation: "receipt" }), sceneBeat("trace", "Evidence", "Trace frames and protocol evidence remain inspectable.", { chatMode: "dock", chatTheme: "light", evidencePresentation: "receipt" }),
], ],
@@ -729,7 +729,7 @@
} }
.demo-workflow-stage[data-demo-layout="approval"] { .demo-workflow-stage[data-demo-layout="approval"] {
grid-template-columns: minmax(0, 1.35fr) minmax(18rem, 0.65fr); grid-template-columns: minmax(0, 1fr);
} }
.demo-workflow-stage[data-demo-layout="evidence"] { .demo-workflow-stage[data-demo-layout="evidence"] {