fix: clarify schema approval presentation
This commit is contained in:
@@ -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"] {
|
||||||
|
|||||||
Reference in New Issue
Block a user