fix: resume workflow on revision requests

This commit is contained in:
lda
2026-07-11 23:04:47 +07:00 Verified
parent 31fb170dba
commit d99447ebd7
30 changed files with 411 additions and 187 deletions
@@ -35,7 +35,7 @@ const demo: DemoTimelineController = {
play: noop,
next: noopAsync,
submitSelectedIssues: noopAsync,
cancelReview: noopAsync,
requestRevision: noopAsync,
restart: noop,
primeReplayToStage: noop,
};
@@ -192,14 +192,14 @@ describe("DemoWorkflowScene", () => {
approvalActions: {
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
},
});
expect(screen.getByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
});
it("shows a factual decision form for the approval beat instead of raw schema as the primary visual", () => {
@@ -38,7 +38,7 @@ const demo = {
play: vi.fn(),
next: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
requestRevision: vi.fn(),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
} as unknown as DemoTimelineController;
@@ -63,9 +63,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
@@ -109,9 +109,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
@@ -134,9 +134,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
@@ -156,9 +156,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
@@ -180,9 +180,9 @@ describe("GuidedProductMoment", () => {
approvalActions={{
state: "ready",
canSubmit: true,
canCancel: true,
canRequestRevision: true,
submit: vi.fn(async () => {}),
cancel: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
openEvidence={vi.fn()}
/>,
@@ -35,8 +35,8 @@ const statusCopy = (
): string => {
if (moment !== "approval") return "Same persisted run; inspect the proof below.";
if (approvalActions?.state === "submitted") return "Submitted. Same run resumed.";
if (approvalActions?.state === "cancelled") {
return "Cancelled in presentation replay. No resume evidence is shown.";
if (approvalActions?.state === "revision_requested") {
return "Revision requested. The same run resumed through its negative outcome branch.";
}
return "Run is paused. Submit resumes this same run.";
};
@@ -67,6 +67,9 @@ export const GuidedProductMoment = ({
const lens = demoBeatLensForBeat(beat.id);
const facts = projectDemoRunFacts(demo);
const runResume = demo.state.events.find((event) => event.stage === "run_resume");
const headline = moment === "resume" && approvalActions?.state === "revision_requested"
? "The revision request continues the persisted run"
: lens.headline;
return (
<section
@@ -80,7 +83,7 @@ export const GuidedProductMoment = ({
>
<header className="guided-product-moment__header">
<span>{lens.eyebrow}</span>
<strong>{lens.headline}</strong>
<strong>{headline}</strong>
<p>{statusCopy(moment, approvalActions)}</p>
</header>
@@ -96,9 +99,9 @@ export const GuidedProductMoment = ({
interrupt={facts.interrupt}
runId={demo.state.events.find((e) => e.stage === "run_start")?.resultingIds.runId ?? "unknown"}
onSubmit={approvalActions?.canSubmit ? (ids, comment) => approvalActions.submit(ids, comment) : undefined}
onCancel={approvalActions?.canCancel ? () => approvalActions.cancel() : undefined}
onRequestRevision={approvalActions?.canRequestRevision ? () => approvalActions.requestRevision() : undefined}
terminalOutcome={approvalActions?.state === "submitted" ? "submitted" :
approvalActions?.state === "cancelled" ? "cancelled" : undefined}
approvalActions?.state === "revision_requested" ? "revision requested" : undefined}
showReportPreview={false}
/>
</div>
@@ -56,7 +56,7 @@ export const InterruptContractPreview = ({
runId={contract.runId}
state={approvalActions?.state ?? "ready"}
onSubmit={approvalActions?.canSubmit ? () => void approvalActions.submit() : undefined}
onCancel={approvalActions?.canCancel ? () => void approvalActions.cancel() : undefined}
onRequestRevision={approvalActions?.canRequestRevision ? () => void approvalActions.requestRevision() : undefined}
/>
) : (
<div className="interrupt-contract-preview__details">
@@ -34,7 +34,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRequestRevision={vi.fn()}
/>,
);
@@ -52,7 +52,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRequestRevision={vi.fn()}
/>,
);
@@ -69,7 +69,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={onSubmit}
onCancel={vi.fn()}
onRequestRevision={vi.fn()}
/>,
);
@@ -91,8 +91,8 @@ describe("InterruptDecisionForm", () => {
);
});
it("calls cancel callback without submit", async () => {
const onCancel = vi.fn();
it("requests revision without submit", async () => {
const onRequestRevision = vi.fn();
const onSubmit = vi.fn();
const user = userEvent.setup();
@@ -101,13 +101,13 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={onSubmit}
onCancel={onCancel}
onRequestRevision={onRequestRevision}
/>,
);
await user.click(screen.getByRole("button", { name: /cancel/i }));
await user.click(screen.getByRole("button", { name: /request revision/i }));
expect(onCancel).toHaveBeenCalledOnce();
expect(onRequestRevision).toHaveBeenCalledOnce();
expect(onSubmit).not.toHaveBeenCalled();
});
@@ -117,14 +117,14 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRequestRevision={vi.fn()}
terminalOutcome="submitted"
/>,
);
expect(screen.getByText(/submitted/i)).toBeDefined();
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /request revision/i })).toBeFalsy();
});
it("can leave report evidence to the surrounding interrupt panel", () => {
@@ -133,7 +133,7 @@ describe("InterruptDecisionForm", () => {
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={vi.fn()}
onCancel={vi.fn()}
onRequestRevision={vi.fn()}
showReportPreview={false}
/>,
);
@@ -142,19 +142,19 @@ describe("InterruptDecisionForm", () => {
expect(screen.getAllByRole("checkbox")).toHaveLength(2);
});
it("shows terminal outcome label when state is cancelled", () => {
it("shows terminal outcome label when revision is requested", () => {
render(
<InterruptDecisionForm
interrupt={interrupt}
runId="run_recorded_lda_report"
onSubmit={vi.fn()}
onCancel={vi.fn()}
terminalOutcome="cancelled"
onRequestRevision={vi.fn()}
terminalOutcome="revision requested"
/>,
);
expect(screen.getByText(/cancelled/i)).toBeDefined();
expect(screen.getByText(/revision requested/i)).toBeDefined();
expect(screen.queryByRole("button", { name: /submit/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /cancel/i })).toBeFalsy();
expect(screen.queryByRole("button", { name: /request revision/i })).toBeFalsy();
});
});
@@ -5,8 +5,8 @@ type InterruptDecisionFormProps = {
readonly interrupt: RunFactsInterrupt;
readonly runId: string;
readonly onSubmit?: ((selectedIssueIds: ReadonlyArray<string>, comment: string) => void) | undefined;
readonly onCancel?: (() => void) | undefined;
readonly terminalOutcome?: "submitted" | "cancelled" | undefined;
readonly onRequestRevision?: (() => void) | undefined;
readonly terminalOutcome?: "submitted" | "revision requested" | undefined;
readonly showReportPreview?: boolean;
};
@@ -14,7 +14,7 @@ export const InterruptDecisionForm = ({
interrupt,
runId,
onSubmit,
onCancel,
onRequestRevision,
terminalOutcome,
showReportPreview = true,
}: InterruptDecisionFormProps) => {
@@ -111,10 +111,10 @@ export const InterruptDecisionForm = ({
<button
type="button"
className="interrupt-decision-form__cancel"
onClick={() => onCancel?.()}
disabled={!onCancel}
onClick={() => onRequestRevision?.()}
disabled={!onRequestRevision}
>
Cancel
Request revision
</button>
</div>
</form>
@@ -101,7 +101,7 @@ describe("OperatorChat", () => {
it("renders schema approval surface inside chat approval request", async () => {
const user = userEvent.setup();
const onApprove = vi.fn();
const onDeny = vi.fn();
const onRequestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
@@ -124,14 +124,14 @@ describe("OperatorChat", () => {
},
];
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onDeny={onDeny} />);
render(<OperatorChat state={initialPresentationState} messages={messages} onApprove={onApprove} onRequestRevision={onRequestRevision} />);
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: /cancel/i }));
await user.click(screen.getByRole("button", { name: /request revision/i }));
expect(onApprove).toHaveBeenCalledTimes(1);
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onRequestRevision).toHaveBeenCalledTimes(1);
});
it("falls back to tool card display when approval request has no contract", async () => {
@@ -194,7 +194,7 @@ describe("OperatorChat", () => {
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
}}
/>,
);
@@ -203,10 +203,10 @@ describe("OperatorChat", () => {
expect(runPreparedWorkflow).toHaveBeenCalledTimes(1);
});
it("routes schema approval submit and cancel through the timeline agent when present", async () => {
it("routes schema approval submit and revision request through the timeline agent when present", async () => {
const user = userEvent.setup();
const submitSelectedIssues = vi.fn(async () => {});
const cancelReview = vi.fn(async () => {});
const requestRevision = vi.fn(async () => {});
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
@@ -239,15 +239,15 @@ describe("OperatorChat", () => {
runLabel: "Run prepared workflow",
runPreparedWorkflow: vi.fn(async () => {}),
submitSelectedIssues,
cancelReview,
requestRevision,
}}
/>,
);
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: /request revision/i }));
expect(submitSelectedIssues).toHaveBeenCalledTimes(1);
expect(cancelReview).toHaveBeenCalledTimes(1);
expect(requestRevision).toHaveBeenCalledTimes(1);
});
it("renders prepared run tool calls as workflow handoffs", () => {
@@ -297,18 +297,18 @@ describe("OperatorChat", () => {
state={initialPresentationState}
messages={messages}
onApprove={undefined}
onDeny={undefined}
onRequestRevision={undefined}
/>,
);
expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Request revision" })).toBeDisabled();
});
it("routes approval requests through provided approval callbacks", async () => {
const user = userEvent.setup();
const approve = vi.fn();
const deny = vi.fn();
const requestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
@@ -336,14 +336,14 @@ describe("OperatorChat", () => {
state={initialPresentationState}
messages={messages}
onApprove={approve}
onDeny={deny}
onRequestRevision={requestRevision}
/>,
);
await user.click(screen.getByRole("button", { name: "Submit" }));
expect(approve).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Cancel" }));
expect(deny).toHaveBeenCalledOnce();
await user.click(screen.getByRole("button", { name: "Request revision" }));
expect(requestRevision).toHaveBeenCalledOnce();
});
});
@@ -10,7 +10,7 @@ type OperatorChatProps = {
readonly messages?: ReadonlyArray<AgentMessage> | undefined;
readonly timelineAgent?: TimelineAgentController | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly onRequestRevision?: (() => void) | undefined;
};
const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [
@@ -30,7 +30,7 @@ const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage>
},
];
export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny }: OperatorChatProps) => {
export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onRequestRevision }: OperatorChatProps) => {
const visibleMessages = messages && messages.length > 0
? messages
: timelineAgent && timelineAgent.messages.length > 0
@@ -39,9 +39,9 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
const submit = timelineAgent
? () => { timelineAgent.submitSelectedIssues().catch(console.error); }
: onApprove;
const cancel = timelineAgent
? () => { timelineAgent.cancelReview().catch(console.error); }
: onDeny;
const requestRevision = timelineAgent
? () => { timelineAgent.requestRevision().catch(console.error); }
: onRequestRevision;
const composition = compositionForState(state);
const presentationSurface = composition.chatTheme === "light" ? "editorial" : "night";
return (
@@ -61,7 +61,7 @@ export const OperatorChat = ({ state, messages, timelineAgent, onApprove, onDeny
run: () => void timelineAgent.runPreparedWorkflow(),
} : undefined}
submitApproval={submit}
cancelApproval={cancel}
requestRevision={requestRevision}
/>
</aside>
);
@@ -338,27 +338,26 @@ describe("PresentationRoute", () => {
window.dispatchEvent(new HashChangeEvent("hashchange"));
expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
});
it("cancels Scene 10 approval in replay without applying submitted evidence", async () => {
it("requests revision and resumes Scene 10 through the negative branch", async () => {
const user = userEvent.setup();
setReplayMode();
window.location.hash = "#scene/typed-human-boundary/approval";
const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />);
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
await waitFor(() => expect(cancelButton).toBeEnabled(), { timeout: 10000 });
const revisionButton = await screen.findByRole("button", { name: "Request revision" });
await waitFor(() => expect(revisionButton).toBeEnabled(), { timeout: 10000 });
await act(async () => {
await user.click(cancelButton);
await user.click(revisionButton);
});
expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Cancel" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#scene/typed-human-boundary/approval");
expect(screen.queryByLabelText("workflow.runs.resume operation")).not.toBeInTheDocument();
expect(window.location.hash).toBe("#scene/resume-output-evidence/resume");
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
expect(screen.getByText(/Revision Requested/i)).toBeInTheDocument();
});
it("opens approval with enabled approval controls immediately after priming", async () => {
@@ -368,7 +367,7 @@ describe("PresentationRoute", () => {
render(<PresentationRoute />);
expect(await screen.findByRole("button", { name: "Submit" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeEnabled();
expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
});
it("opens resume with resume operation proof immediately after priming", async () => {
@@ -181,26 +181,30 @@ export const PresentationRoute = () => {
});
}, [demo]);
const handleCancelApproval = useCallback(async () => {
const handleRequestRevision = useCallback(async () => {
if (demo.state.phase !== "review") return;
setApprovalState("cancelled");
await demo.cancelReview("Cancelled by operator.");
// The canonical replay only records the submitted branch. Do not call
// next() in replay, or the UI would falsely show submitted run evidence.
if (demo.state.mode === "live") {
await demo.next();
}
setApprovalState("revision_requested");
await demo.requestRevision("Request revisions before creating issues.");
if (presentationTarget.mode === "live") await demo.next();
dispatch({
type: "jump",
location: {
kind: "main",
sceneId: "resume-output-evidence",
beatId: "resume",
focusPath: [],
},
});
}, [demo]);
const approvalActions = useMemo<DemoApprovalActions>(() => ({
state: approvalState,
canSubmit: demo.state.phase === "review" && selectedIssueIdsForDemo(demo.interruptPayload).length > 0,
canCancel: demo.state.phase === "review",
canRequestRevision: demo.state.phase === "review",
submit: handleSubmitApproval,
cancel: handleCancelApproval,
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleCancelApproval]);
requestRevision: handleRequestRevision,
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleRequestRevision]);
useEffect(() => {
if (!isApprovalBeat || demo.state.phase !== "review") return;
@@ -22,7 +22,7 @@ type PresentationStageProps = {
readonly timelineAgent?: TimelineAgentController | undefined;
readonly approvalActions?: DemoApprovalActions | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly onRequestRevision?: (() => void) | undefined;
readonly targetStatus: PresentationTargetHealth;
readonly jump: (location: MainLocation) => void;
readonly selectNode: (nodeId: string | null) => void;
@@ -40,7 +40,7 @@ export const PresentationStage = ({
timelineAgent,
approvalActions,
onApprove,
onDeny,
onRequestRevision,
targetStatus,
jump,
selectNode,
@@ -66,7 +66,7 @@ export const PresentationStage = ({
data-scene-view={activeSceneView}
>
<aside className="presentation-stage__chat" aria-label="agent chat region">
<OperatorChat state={state} messages={messages} timelineAgent={timelineAgent} onApprove={onApprove} onDeny={onDeny} />
<OperatorChat state={state} messages={messages} timelineAgent={timelineAgent} onApprove={onApprove} onRequestRevision={onRequestRevision} />
</aside>
<section className="presentation-stage__primary" aria-label="primary presentation region">
{state.location.kind === "discussion" ? (
@@ -32,7 +32,7 @@ const demo: DemoTimelineController = {
play: noop,
next: noopAsync,
submitSelectedIssues: noopAsync,
cancelReview: noopAsync,
requestRevision: noopAsync,
restart: noop,
primeReplayToStage: noop,
};
@@ -7,7 +7,7 @@ afterEach(() => cleanup());
describe("SchemaApprovalSurface", () => {
it("renders explicit schema fields and outcome actions", () => {
const onSubmit = vi.fn();
const onCancel = vi.fn();
const onRequestRevision = vi.fn();
render(
<SchemaApprovalSurface
@@ -24,7 +24,7 @@ describe("SchemaApprovalSurface", () => {
outcomes={["submitted", "cancelled"]}
runId="run_recorded_lda_report"
onSubmit={onSubmit}
onCancel={onCancel}
onRequestRevision={onRequestRevision}
/>,
);
@@ -35,9 +35,9 @@ describe("SchemaApprovalSurface", () => {
expect(within(surface).getByText("run_recorded_lda_report")).toBeInTheDocument();
fireEvent.click(within(surface).getByRole("button", { name: /submit/i }));
fireEvent.click(within(surface).getByRole("button", { name: /cancel/i }));
fireEvent.click(within(surface).getByRole("button", { name: /request revision/i }));
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onRequestRevision).toHaveBeenCalledTimes(1);
});
it("renders payload preview for loose object schemas", () => {
@@ -56,7 +56,7 @@ describe("SchemaApprovalSurface", () => {
expect(screen.getByText("[\"risk-1\"]")).toBeInTheDocument();
});
it("shows submitted and cancelled states without active actions", () => {
it("shows submitted and revision-requested states without active actions", () => {
const { rerender } = render(
<SchemaApprovalSurface
title="Issue review resume"
@@ -77,10 +77,10 @@ describe("SchemaApprovalSurface", () => {
payload={{}}
outcomes={["submitted", "cancelled"]}
runId={null}
state="cancelled"
state="revision_requested"
/>,
);
expect(screen.getByText("Outcome: cancelled")).toBeInTheDocument();
expect(screen.getByText("Outcome: revision requested")).toBeInTheDocument();
});
});
@@ -6,9 +6,9 @@ export type SchemaApprovalSurfaceProps = {
readonly payload: unknown;
readonly outcomes: ReadonlyArray<string>;
readonly runId: string | null;
readonly state?: "ready" | "submitted" | "cancelled";
readonly state?: "ready" | "submitted" | "revision_requested";
readonly onSubmit?: (() => void) | undefined;
readonly onCancel?: (() => void) | undefined;
readonly onRequestRevision?: (() => void) | undefined;
};
export const SchemaApprovalSurface = ({
@@ -19,10 +19,11 @@ export const SchemaApprovalSurface = ({
runId,
state = "ready",
onSubmit,
onCancel,
onRequestRevision,
}: SchemaApprovalSurfaceProps) => {
const model = buildSchemaApprovalModel({ schema, payload, outcomes });
const isResolved = state !== "ready";
const stateLabel = state === "revision_requested" ? "revision requested" : state;
return (
<section className="schema-approval-surface" role="group" aria-label={title} data-state={state}>
@@ -65,14 +66,14 @@ export const SchemaApprovalSurface = ({
<footer className="schema-approval-surface__actions">
{isResolved ? (
<strong>Outcome: {state}</strong>
<strong>Outcome: {stateLabel}</strong>
) : (
<>
<button type="button" onClick={onSubmit} disabled={!onSubmit}>
Submit
</button>
<button type="button" onClick={onCancel} disabled={!onCancel}>
Cancel
<button type="button" onClick={onRequestRevision} disabled={!onRequestRevision}>
Request revision
</button>
</>
)}
@@ -78,7 +78,7 @@ describe("AgentHandoffScene", () => {
runLabel: "Run prepared workflow",
runPreparedWorkflow,
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
} as unknown as TimelineAgentController;
render(
@@ -60,7 +60,7 @@ describe("AssistantOperatorThread", () => {
it("renders schema approval through the existing approval surface", async () => {
const user = userEvent.setup();
const submit = vi.fn();
const cancel = vi.fn();
const requestRevision = vi.fn();
const messages: ReadonlyArray<AgentMessage> = [
{
id: "approval",
@@ -88,15 +88,15 @@ describe("AssistantOperatorThread", () => {
mode="dock"
messages={messages}
submitApproval={submit}
cancelApproval={cancel}
requestRevision={requestRevision}
/>,
);
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Submit" }));
await user.click(screen.getByRole("button", { name: "Cancel" }));
await user.click(screen.getByRole("button", { name: "Request revision" }));
expect(submit).toHaveBeenCalledOnce();
expect(cancel).toHaveBeenCalledOnce();
expect(requestRevision).toHaveBeenCalledOnce();
});
it("renders a chat-owned run action", async () => {
@@ -25,7 +25,7 @@ type AssistantOperatorThreadProps = {
readonly messages: ReadonlyArray<AgentMessage>;
readonly runAction?: { readonly label: string; readonly disabled: boolean; readonly run: () => void } | undefined;
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
readonly requestRevision?: (() => void) | undefined;
readonly ariaLabel?: string | undefined;
readonly surface?: "stage" | "dock" | undefined;
readonly activeToolGroupId?: string | undefined;
@@ -87,7 +87,7 @@ const resultForToolPart = (part: ToolRenderPart): unknown =>
const renderContentPart = (
part: AssistantContentPart,
submitApproval?: (() => void) | undefined,
cancelApproval?: (() => void) | undefined,
requestRevision?: (() => void) | undefined,
defaultOpen?: boolean,
pairedResult?: Extract<ToolRenderPart, { readonly type: "tool-result" }>,
): ReactNode => {
@@ -116,7 +116,7 @@ const renderContentPart = (
outcomes={contract.outcomes}
runId={contract.runId}
onSubmit={submitApproval}
onCancel={cancelApproval}
onRequestRevision={requestRevision}
/>
</div>
</ToolFallbackContent>
@@ -141,14 +141,14 @@ const AssistantMessageBody = ({
openToolGroups,
setToolGroupOpen,
submitApproval,
cancelApproval,
requestRevision,
}: {
readonly messageId: string;
readonly parts: readonly AssistantContentPart[];
readonly openToolGroups: ReadonlySet<string>;
readonly setToolGroupOpen: (groupId: string, open: boolean) => void;
readonly submitApproval?: (() => void) | undefined;
readonly cancelApproval?: (() => void) | undefined;
readonly requestRevision?: (() => void) | undefined;
}) => {
const rendered: ReactNode[] = [];
let index = 0;
@@ -176,7 +176,7 @@ const AssistantMessageBody = ({
if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) {
rendered.push(
<div key={`tool-${logicalTools[0]!.toolCallId ?? logicalTools[0]!.toolName}`}>
{renderContentPart(logicalTools[0]!, submitApproval, cancelApproval)}
{renderContentPart(logicalTools[0]!, submitApproval, requestRevision)}
</div>,
);
continue;
@@ -209,7 +209,7 @@ const AssistantMessageBody = ({
: undefined;
return (
<div key={`${tool.type}-${tool.toolName}-${tool.toolCallId ?? "no-id"}`}>
{renderContentPart(tool, submitApproval, cancelApproval, false, pairedResult)}
{renderContentPart(tool, submitApproval, requestRevision, false, pairedResult)}
</div>
);
})}
@@ -246,7 +246,7 @@ export const AssistantOperatorThread = ({
messages,
runAction,
submitApproval,
cancelApproval,
requestRevision,
ariaLabel = "operator conversation",
surface,
activeToolGroupId,
@@ -324,7 +324,7 @@ export const AssistantOperatorThread = ({
openToolGroups={openToolGroups}
setToolGroupOpen={setToolGroupOpen}
submitApproval={submitApproval}
cancelApproval={cancelApproval}
requestRevision={requestRevision}
/>
</MessageBubble>
);
@@ -1,12 +1,12 @@
export type DemoApprovalUiState = "ready" | "submitted" | "cancelled";
export type DemoApprovalUiState = "ready" | "submitted" | "revision_requested";
export type DemoApprovalActions = {
readonly state: DemoApprovalUiState;
readonly canSubmit: boolean;
readonly canCancel: boolean;
readonly canRequestRevision: boolean;
readonly submit: (
selectedIssueIds?: ReadonlyArray<string>,
comment?: string,
) => Promise<void>;
readonly cancel: () => Promise<void>;
readonly requestRevision: () => Promise<void>;
};
@@ -38,7 +38,7 @@ const controller = (overrides: Partial<DemoTimelineController> = {}): DemoTimeli
play: vi.fn(),
next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
...overrides,