fix: keep replay cancellation honest
This commit is contained in:
@@ -89,4 +89,25 @@ describe("useTimelineAgent", () => {
|
|||||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||||
expect(result.current.canRun).toBe(false);
|
expect(result.current.canRun).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not advance replay cancellation into the submitted recording branch", async () => {
|
||||||
|
const cancelReview = vi.fn(async () => {});
|
||||||
|
const next = vi.fn(async () => {});
|
||||||
|
const demo = demoController({
|
||||||
|
state: { ...initialDemoTimelineState, mode: "replay", phase: "review" },
|
||||||
|
cancelReview,
|
||||||
|
next,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
||||||
|
await act(async () => result.current.cancelReview());
|
||||||
|
|
||||||
|
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.messages.at(-1)?.parts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "tool-result" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,7 +88,11 @@ export const useTimelineAgent = (
|
|||||||
|
|
||||||
const cancelReview = useCallback(async () => {
|
const cancelReview = useCallback(async () => {
|
||||||
await demo.cancelReview("Cancelled by operator.");
|
await demo.cancelReview("Cancelled by operator.");
|
||||||
await demo.next();
|
// In replay the canonical recording only contains the submitted branch.
|
||||||
|
// Do not call next() or the UI would falsely advance into submitted evidence.
|
||||||
|
if (demo.state.mode === "live") {
|
||||||
|
await demo.next();
|
||||||
|
}
|
||||||
setMessages((current) => appendToolMessage(
|
setMessages((current) => appendToolMessage(
|
||||||
current,
|
current,
|
||||||
"timeline-agent-cancel",
|
"timeline-agent-cancel",
|
||||||
|
|||||||
@@ -212,4 +212,23 @@ describe("PresentationRoute", () => {
|
|||||||
expect(window.location.hash).toBe("#scene/interrupt-evidence/resume");
|
expect(window.location.hash).toBe("#scene/interrupt-evidence/resume");
|
||||||
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
|
expect(screen.getByLabelText("workflow.runs.resume operation")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("cancels Scene 10 approval in replay without applying submitted evidence", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
setReplayMode();
|
||||||
|
window.location.hash = "#scene/interrupt-evidence/approval";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
const cancelButton = await screen.findByRole("button", { name: "Cancel" });
|
||||||
|
await waitFor(() => expect(cancelButton).toBeEnabled(), { timeout: 10000 });
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await user.click(cancelButton);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText(/Outcome: cancelled/i)).toBeInTheDocument();
|
||||||
|
expect(window.location.hash).toBe("#scene/interrupt-evidence/approval");
|
||||||
|
expect(screen.queryByLabelText("workflow.runs.resume operation")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -135,8 +135,17 @@ export const PresentationRoute = () => {
|
|||||||
}, [demo]);
|
}, [demo]);
|
||||||
|
|
||||||
const handleCancelApproval = useCallback(async () => {
|
const handleCancelApproval = useCallback(async () => {
|
||||||
|
if (demo.state.phase !== "review") return;
|
||||||
|
|
||||||
setApprovalState("cancelled");
|
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();
|
||||||
|
}
|
||||||
|
}, [demo]);
|
||||||
|
|
||||||
const approvalActions = useMemo<DemoApprovalActions>(() => ({
|
const approvalActions = useMemo<DemoApprovalActions>(() => ({
|
||||||
state: approvalState,
|
state: approvalState,
|
||||||
@@ -147,10 +156,10 @@ export const PresentationRoute = () => {
|
|||||||
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleCancelApproval]);
|
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleCancelApproval]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (demo.state.phase === "ready" || demo.state.phase === "running") {
|
if ((demo.state.phase === "ready" || demo.state.phase === "running") && approvalState === "ready") {
|
||||||
setApprovalState("ready");
|
setApprovalState("ready");
|
||||||
}
|
}
|
||||||
}, [demo.state.phase]);
|
}, [demo.state.phase, approvalState]);
|
||||||
|
|
||||||
const handleJump = useCallback(
|
const handleJump = useCallback(
|
||||||
(location: MainLocation) => dispatch({ type: "jump", location }),
|
(location: MainLocation) => dispatch({ type: "jump", location }),
|
||||||
|
|||||||
Reference in New Issue
Block a user