fix: harden presentation replay state

This commit is contained in:
lda
2026-07-11 23:50:31 +07:00 Verified
parent 1dcc5b5f58
commit bd67f260dc
10 changed files with 77 additions and 27 deletions
+1 -2
View File
@@ -167,8 +167,7 @@ Implementation order:
shows input/interruption/decision only, and resume/output/trace expose shows input/interruption/decision only, and resume/output/trace expose
scroll-contained proof panes. Implementation: scroll-contained proof panes. Implementation:
[`presentation demo proof composition`](historical/superpowers/plans/2026-07-09-presentation-demo-proof-composition.md). [`presentation demo proof composition`](historical/superpowers/plans/2026-07-09-presentation-demo-proof-composition.md).
23. Future: presenter companion, final scene visuals, evidence assets, and 23. Future: presenter companion and defense evidence assets.
rehearsal timing.
24. Add a static slide/appendix shell only after presentation mode is clear. 24. Add a static slide/appendix shell only after presentation mode is clear.
Astro remains an option, not the default next surface. Astro remains an option, not the default next surface.
25. Completed: presentation agent authoring story creates a canonical prepared 25. Completed: presentation agent authoring story creates a canonical prepared
@@ -405,7 +405,9 @@ describe("useDemoTimeline", () => {
it("replay revision request resumes through the negative branch", async () => { it("replay revision request resumes through the negative branch", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn())); const { result } = renderHook(() =>
useDemoTimeline("http://127.0.0.1:8765/rpc", vi.fn()),
);
act(() => result.current.setMode("replay")); act(() => result.current.setMode("replay"));
act(() => result.current.start()); act(() => result.current.start());
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
+2 -2
View File
@@ -294,7 +294,7 @@ export const useDemoTimeline = (
comment, comment,
outcome: "cancelled", outcome: "cancelled",
}; };
if (target === null) { if (state.mode === "replay") {
const recording = activeRecording.current; const recording = activeRecording.current;
if (recording) { if (recording) {
const revisionRecording = revisionReplayRecording(recording); const revisionRecording = revisionReplayRecording(recording);
@@ -312,7 +312,7 @@ export const useDemoTimeline = (
return; return;
} }
dispatch({ type: "continue_review" }); dispatch({ type: "continue_review" });
}, [projectTransientState, resetRuntime, target]); }, [projectTransientState, resetRuntime, state.mode]);
const restart = useCallback(() => { const restart = useCallback(() => {
resetRuntime(); resetRuntime();
@@ -341,9 +341,10 @@ describe("PresentationRoute", () => {
expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled(); expect(screen.getByRole("button", { name: "Request revision" })).toBeEnabled();
}); });
it("requests revision and resumes Scene 10 through the negative branch", async () => { it("keeps a replay fallback on the revision-requested branch despite a configured live target", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
setReplayMode(); window.sessionStorage.setItem("lda.workflowConsole.target", "http://127.0.0.1:8765/rpc");
mockedCallOperation.mockRejectedValue(new Error("target unavailable"));
window.location.hash = "#scene/typed-human-boundary/approval"; window.location.hash = "#scene/typed-human-boundary/approval";
const { PresentationRoute } = await import("./PresentationRoute.js"); const { PresentationRoute } = await import("./PresentationRoute.js");
render(<PresentationRoute />); render(<PresentationRoute />);
@@ -186,7 +186,7 @@ export const PresentationRoute = () => {
setApprovalState("revision_requested"); setApprovalState("revision_requested");
await demo.requestRevision("Request revisions before creating issues."); await demo.requestRevision("Request revisions before creating issues.");
if (presentationTarget.mode === "live") await demo.next(); if (demo.state.mode === "live") await demo.next();
dispatch({ dispatch({
type: "jump", type: "jump",
location: { location: {
@@ -83,4 +83,16 @@ describe("PreparedAuthoringLifecycleScene", () => {
expect(active).toBeInTheDocument(); expect(active).toBeInTheDocument();
expect(active).toHaveTextContent("Deployment"); expect(active).toHaveTextContent("Deployment");
}); });
it("falls back to discovery for an unexpected storyboard beat", () => {
const scene = findScene("prepared-lifecycle")!;
const knownBeat = findBeat("prepared-lifecycle", "discover")!;
const unexpectedBeat = { ...knownBeat, id: "unexpected" };
render(<PreparedAuthoringLifecycleScene scene={scene} beat={unexpectedBeat} />);
expect(screen.getByRole("region", { name: "discovery evidence" })).toBeInTheDocument();
expect(screen.getByLabelText("authoring phase rail").querySelector("[data-active='true']"))
.toHaveTextContent("Discover");
});
}); });
@@ -26,7 +26,9 @@ const phases: readonly { readonly id: AuthoringPhaseId; readonly label: string }
* remains below the canvas as a beat-synchronized assistant dock. * remains below the canvas as a beat-synchronized assistant dock.
*/ */
export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthoringLifecycleSceneProps) => { export const PreparedAuthoringLifecycleScene = ({ scene, beat }: PreparedAuthoringLifecycleSceneProps) => {
const beatId = beat.id as AuthoringPhaseId; // Storyboard beats normally match these IDs. Discovery is a safe projection
// if a future beat reaches this scene before its authoring mapping is added.
const beatId = phases.find((phase) => phase.id === beat.id)?.id ?? "discover";
const projection = projectPreparedAuthoringPhase(beatId); const projection = projectPreparedAuthoringPhase(beatId);
return ( return (
@@ -49,8 +49,10 @@ describe("projectPreparedAuthoringPhase", () => {
const diagnosticCmd = phase.commands.find( const diagnosticCmd = phase.commands.find(
(cmd) => cmd.result === "diagnostic" && cmd.detail?.includes("no state projection"), (cmd) => cmd.result === "diagnostic" && cmd.detail?.includes("no state projection"),
); );
const repairCmd = phase.commands.find((cmd) => cmd.command.includes("draft set-output"));
expect(diagnosticCmd).toBeDefined(); expect(diagnosticCmd).toBeDefined();
expect(phase.commands.some((cmd) => cmd.command.includes("draft set-output"))).toBe(true); expect(repairCmd).toBeDefined();
expect(diagnosticCmd).not.toBe(repairCmd);
}); });
it("uses real public command syntax from the recording", () => { it("uses real public command syntax from the recording", () => {
@@ -142,11 +142,7 @@ describe("AssistantOperatorThread", () => {
it("scrolls the active authoring group into the dock viewport", async () => { it("scrolls the active authoring group into the dock viewport", async () => {
const setScrollTop = vi.fn(); const setScrollTop = vi.fn();
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", { const originalDescriptor = Object.getOwnPropertyDescriptor(HTMLDivElement.prototype, "scrollTop");
configurable: true,
get: () => 0,
set: setScrollTop,
});
const messages: ReadonlyArray<AgentMessage> = [ const messages: ReadonlyArray<AgentMessage> = [
{ {
id: "authoring-draft-tools", id: "authoring-draft-tools",
@@ -158,16 +154,47 @@ describe("AssistantOperatorThread", () => {
}, },
]; ];
render( try {
<AssistantOperatorThread Object.defineProperty(HTMLDivElement.prototype, "scrollTop", {
mode="dock" configurable: true,
surface="dock" get: () => 0,
messages={messages} set: setScrollTop,
activeToolGroupId="authoring-draft" });
/>, render(
); <AssistantOperatorThread
mode="dock"
surface="dock"
messages={messages}
activeToolGroupId="authoring-draft"
/>,
);
await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0)); await waitFor(() => expect(setScrollTop).toHaveBeenCalledWith(0));
} finally {
if (originalDescriptor) {
Object.defineProperty(HTMLDivElement.prototype, "scrollTop", originalDescriptor);
} else {
delete (HTMLDivElement.prototype as { scrollTop?: number }).scrollTop;
}
}
});
it("pairs a lone tool call with its result", () => {
const messages: ReadonlyArray<AgentMessage> = [
{
id: "assistant-tool-result",
role: "assistant",
parts: [
{ type: "tool-call", call: { id: "call-1", name: "readRunTrace", input: {} } },
{ type: "tool-result", result: { callId: "call-1", name: "readRunTrace", status: "success", output: { frames: 3 } } },
],
},
];
const { container } = render(<AssistantOperatorThread mode="dock" messages={messages} />);
expect(container.querySelector('[data-slot="tool-fallback-result"]')).toBeInTheDocument();
expect(screen.getByText(/"frames": 3/)).toBeInTheDocument();
}); });
it("renders structured tool results through the generated fallback result slot", () => { it("renders structured tool results through the generated fallback result slot", () => {
@@ -157,7 +157,7 @@ const AssistantMessageBody = ({
const part = parts[index]!; const part = parts[index]!;
if (part.type === "text") { if (part.type === "text") {
rendered.push( rendered.push(
<p key={`text-${part.text}`} style={{ whiteSpace: "pre-line" }}>{part.text}</p>, <p key={`text-${index}`} style={{ whiteSpace: "pre-line" }}>{part.text}</p>,
); );
index += 1; index += 1;
continue; continue;
@@ -174,9 +174,14 @@ const AssistantMessageBody = ({
const logicalTools = calls.length > 0 ? calls : toolRun; const logicalTools = calls.length > 0 ? calls : toolRun;
if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) { if (logicalTools.length === 1 && !messageId.startsWith("authoring-")) {
const tool = logicalTools[0]!;
const pairedResult = tool.type === "tool-call"
? toolRun.find((candidate): candidate is Extract<ToolRenderPart, { readonly type: "tool-result" }> =>
candidate.type === "tool-result" && candidate.toolCallId === tool.toolCallId)
: undefined;
rendered.push( rendered.push(
<div key={`tool-${logicalTools[0]!.toolCallId ?? logicalTools[0]!.toolName}`}> <div key={`tool-${tool.toolCallId ?? tool.toolName}`}>
{renderContentPart(logicalTools[0]!, submitApproval, requestRevision)} {renderContentPart(tool, submitApproval, requestRevision, undefined, pairedResult)}
</div>, </div>,
); );
continue; continue;