fix: keep presentation deep links replay backed
This commit is contained in:
@@ -40,7 +40,7 @@ export const DemoTimelineControls = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="demo-playback-controls">
|
<div className="demo-playback-controls">
|
||||||
{state.phase === "ready" && (
|
{state.phase === "ready" && (
|
||||||
<button onClick={start} disabled={!canStart || inFlight}>Start presentation</button>
|
<button onClick={() => start()} disabled={!canStart || inFlight}>Start presentation</button>
|
||||||
)}
|
)}
|
||||||
{inRunning && (
|
{inRunning && (
|
||||||
<button onClick={pause} disabled={inFlight}>Pause</button>
|
<button onClick={pause} disabled={inFlight}>Pause</button>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe("useTimelineAgent", () => {
|
|||||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||||
await act(async () => result.current.runPreparedWorkflow());
|
await act(async () => result.current.runPreparedWorkflow());
|
||||||
|
|
||||||
expect(start).toHaveBeenCalledTimes(1);
|
expect(start).toHaveBeenCalledWith("live");
|
||||||
expect(result.current.messages.at(-1)?.parts).toEqual(
|
expect(result.current.messages.at(-1)?.parts).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({ type: "tool-result" }),
|
expect.objectContaining({ type: "tool-result" }),
|
||||||
@@ -42,6 +42,16 @@ describe("useTimelineAgent", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("starts the replay walkthrough explicitly when replay is active", async () => {
|
||||||
|
const start = vi.fn();
|
||||||
|
const demo = demoController({ start });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
||||||
|
await act(async () => result.current.runPreparedWorkflow());
|
||||||
|
|
||||||
|
expect(start).toHaveBeenCalledWith("replay");
|
||||||
|
});
|
||||||
|
|
||||||
it("submits selected issues from the current interrupt payload", async () => {
|
it("submits selected issues from the current interrupt payload", async () => {
|
||||||
const submitSelectedIssues = vi.fn(async () => {});
|
const submitSelectedIssues = vi.fn(async () => {});
|
||||||
const demo = demoController({
|
const demo = demoController({
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const useTimelineAgent = (
|
|||||||
"timeline-agent-intro",
|
"timeline-agent-intro",
|
||||||
"assistant",
|
"assistant",
|
||||||
modeLabel === "live"
|
modeLabel === "live"
|
||||||
? "Live workflow server is available. I can run the prepared workflow now."
|
? "Live workflow target is configured. I can run the prepared workflow now."
|
||||||
: "Replay fallback is active. I can still walk through the prepared workflow evidence.",
|
: "Replay fallback is active. I can still walk through the prepared workflow evidence.",
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
@@ -58,8 +58,7 @@ export const useTimelineAgent = (
|
|||||||
|
|
||||||
const runPreparedWorkflow = useCallback(async () => {
|
const runPreparedWorkflow = useCallback(async () => {
|
||||||
if (!demo.canStart || demo.inFlight) return;
|
if (!demo.canStart || demo.inFlight) return;
|
||||||
demo.restart();
|
demo.start(modeLabel);
|
||||||
demo.start();
|
|
||||||
setMessages((current) => appendToolMessage(
|
setMessages((current) => appendToolMessage(
|
||||||
current,
|
current,
|
||||||
"timeline-agent-start",
|
"timeline-agent-start",
|
||||||
|
|||||||
@@ -35,6 +35,29 @@ describe("useDemoTimeline", () => {
|
|||||||
expect(result.current.state.appliedCount).toBeGreaterThan(0);
|
expect(result.current.state.appliedCount).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can force replay start without first switching timeline mode", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { result } = renderHook(() => useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.start("replay"));
|
||||||
|
await act(async () => vi.advanceTimersByTimeAsync(900));
|
||||||
|
|
||||||
|
expect(result.current.state.mode).toBe("replay");
|
||||||
|
expect(mockedCallOperation).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.state.appliedCount).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can force live start from replay mode without stale state", () => {
|
||||||
|
const { result } = renderHook(() => useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()));
|
||||||
|
|
||||||
|
act(() => result.current.setMode("replay"));
|
||||||
|
act(() => result.current.start("live"));
|
||||||
|
|
||||||
|
expect(result.current.state.mode).toBe("live");
|
||||||
|
expect(result.current.state.events).toEqual([]);
|
||||||
|
expect(result.current.state.phase).toBe("running");
|
||||||
|
});
|
||||||
|
|
||||||
it("live Next executes exactly one operation", async () => {
|
it("live Next executes exactly one operation", async () => {
|
||||||
mockedCallOperation.mockResolvedValueOnce({
|
mockedCallOperation.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export type DemoTimelineController = {
|
|||||||
readonly recordingId: string | null;
|
readonly recordingId: string | null;
|
||||||
readonly canStart: boolean;
|
readonly canStart: boolean;
|
||||||
readonly setMode: (mode: DemoMode) => void;
|
readonly setMode: (mode: DemoMode) => void;
|
||||||
readonly start: () => void;
|
readonly start: (mode?: DemoMode) => void;
|
||||||
readonly pause: () => void;
|
readonly pause: () => void;
|
||||||
readonly play: () => void;
|
readonly play: () => void;
|
||||||
readonly next: () => Promise<void>;
|
readonly next: () => Promise<void>;
|
||||||
@@ -206,9 +206,10 @@ export const useDemoTimeline = (
|
|||||||
dispatch({ type: "set_mode", mode });
|
dispatch({ type: "set_mode", mode });
|
||||||
}, [resetRuntime]);
|
}, [resetRuntime]);
|
||||||
|
|
||||||
const start = useCallback(() => {
|
const start = useCallback((modeOverride?: DemoMode) => {
|
||||||
resetRuntime();
|
resetRuntime();
|
||||||
if (state.mode === "replay") {
|
const mode = modeOverride ?? state.mode;
|
||||||
|
if (mode === "replay") {
|
||||||
const recording = activeRecording.current;
|
const recording = activeRecording.current;
|
||||||
if (!recording) return;
|
if (!recording) return;
|
||||||
dispatch({ type: "start", mode: "replay", events: recording.events });
|
dispatch({ type: "start", mode: "replay", events: recording.events });
|
||||||
|
|||||||
@@ -173,6 +173,16 @@ describe("PresentationRoute", () => {
|
|||||||
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "approval");
|
expect(screen.getByLabelText("demo workflow stage")).toHaveAttribute("data-demo-layout", "approval");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders replay-backed approval evidence on a direct approval hash", async () => {
|
||||||
|
window.location.hash = "#scene/interrupt-evidence/approval";
|
||||||
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
|
render(<PresentationRoute />);
|
||||||
|
|
||||||
|
expect(await screen.findByLabelText("typed interrupt contract")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("group", { name: /issue review resume/i })).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Recorded resume payload for this decision/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("chat run action advances the replay timeline when no live server is configured", async () => {
|
it("chat run action advances the replay timeline when no live server is configured", async () => {
|
||||||
setReplayMode();
|
setReplayMode();
|
||||||
window.location.hash = "#scene/workflow-demo/operation";
|
window.location.hash = "#scene/workflow-demo/operation";
|
||||||
|
|||||||
@@ -91,19 +91,18 @@ export const PresentationRoute = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (demo.state.phase === "ready" && presentationTarget.mode === "replay" && demo.state.mode !== "replay") {
|
// Scene deep-links need the recorded demo state before the operator starts a live run.
|
||||||
|
// The chat action can still switch the shared timeline to live via start("live").
|
||||||
|
if (demo.state.phase === "ready" && demo.state.mode !== "replay") {
|
||||||
demo.setMode("replay");
|
demo.setMode("replay");
|
||||||
}
|
}
|
||||||
if (demo.state.phase === "ready" && presentationTarget.mode === "live" && demo.state.mode !== "live") {
|
}, [demo.state.phase, demo.state.mode, demo.setMode]);
|
||||||
demo.setMode("live");
|
|
||||||
}
|
|
||||||
}, [demo.state.phase, demo.state.mode, demo.setMode, presentationTarget.mode]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (presentationTarget.mode === "replay" && demo.state.phase === "ready" && demo.state.mode === "replay") {
|
if (demo.state.phase === "ready" && demo.state.mode === "replay") {
|
||||||
demo.start();
|
demo.start("replay");
|
||||||
}
|
}
|
||||||
}, [demo.state.phase, demo.state.mode, demo.start, presentationTarget.mode]);
|
}, [demo.state.phase, demo.state.mode, demo.start]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
|
dispatch({ type: "set_playback_mode", mode: demo.state.mode });
|
||||||
@@ -143,4 +142,3 @@ export const PresentationRoute = () => {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user