fix: keep replay cancellation honest

This commit is contained in:
lda
2026-07-09 09:21:39 +07:00 Verified
parent cf3cac1fc8
commit 7db9f5e0b1
4 changed files with 57 additions and 4 deletions
@@ -89,4 +89,25 @@ describe("useTimelineAgent", () => {
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
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 () => {
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(
current,
"timeline-agent-cancel",
@@ -212,4 +212,23 @@ describe("PresentationRoute", () => {
expect(window.location.hash).toBe("#scene/interrupt-evidence/resume");
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]);
const handleCancelApproval = 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();
}
}, [demo]);
const approvalActions = useMemo<DemoApprovalActions>(() => ({
state: approvalState,
@@ -147,10 +156,10 @@ export const PresentationRoute = () => {
}), [approvalState, demo.state.phase, demo.interruptPayload, handleSubmitApproval, handleCancelApproval]);
useEffect(() => {
if (demo.state.phase === "ready" || demo.state.phase === "running") {
if ((demo.state.phase === "ready" || demo.state.phase === "running") && approvalState === "ready") {
setApprovalState("ready");
}
}, [demo.state.phase]);
}, [demo.state.phase, approvalState]);
const handleJump = useCallback(
(location: MainLocation) => dispatch({ type: "jump", location }),