fix: harden presentation replay state
This commit is contained in:
@@ -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++) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user