fix: address presentation review findings

This commit is contained in:
lda
2026-07-08 06:23:58 +07:00 Verified
parent eb40c951d0
commit 20bf11dde1
29 changed files with 717 additions and 122 deletions
@@ -65,9 +65,9 @@ export async function* runPreparedRecipeReplay(
id: step.id,
role: "assistant",
parts: [
agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: "review_issues" }),
presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: "review_issues" }),
agentToolCallPart(`${step.id}-call`, step.toolName, { nodeId: step.toolInput.nodeId }),
presentationActionPart({ type: "selectWorkflowNode", nodeId: step.toolInput.nodeId }),
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { nodeId: step.toolInput.nodeId }),
],
};
break;
+15 -7
View File
@@ -1,18 +1,25 @@
import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
import type { PresentationToolName, WorkflowToolName } from "./tools.js";
export type RecipeTool =
| "inspectDeployment"
| "startPreparedReportRun"
| "selectWorkflowNode"
| "resumeIssueReview"
| "readRunTrace";
| Extract<WorkflowToolName, "inspectDeployment" | "startPreparedReportRun" | "resumeIssueReview" | "readRunTrace">
| Extract<PresentationToolName, "selectWorkflowNode">;
export type PreparedRecipeStep = {
type SelectWorkflowNodeStep = {
readonly id: string;
readonly narration: string;
readonly toolName: RecipeTool | null;
readonly toolName: "selectWorkflowNode";
readonly toolInput: { readonly nodeId: string };
};
type OtherPreparedRecipeStep = {
readonly id: string;
readonly narration: string;
readonly toolName: Exclude<RecipeTool, "selectWorkflowNode"> | null;
};
export type PreparedRecipeStep = SelectWorkflowNodeStep | OtherPreparedRecipeStep;
export type PreparedRecipe = {
readonly id: "prepare-thesis-report";
readonly title: string;
@@ -46,6 +53,7 @@ export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
id: "focus-interrupt",
narration: "Let's zoom into the typed issue-review interrupt.",
toolName: "selectWorkflowNode",
toolInput: { nodeId: "review_issues" },
},
{
id: "resume",
@@ -123,6 +123,32 @@ describe("useDemoAgent", () => {
expect(result.current.pendingActions).toEqual([]);
});
it("aborts a pending approval when the hook unmounts", async () => {
let observedSignal: AbortSignal | null = null;
const driver: AgentDriver = {
kind: "prepared-recipe",
run: async function* (_input, signal, requestApproval) {
observedSignal = signal;
yield {
id: "approval-msg",
role: "assistant",
parts: [{ type: "approval-request", callId: "call-1", name: "resumeIssueReview", prompt: "Approve?" }],
};
await requestApproval(signal);
},
};
const { result, unmount } = renderHook(() => useDemoAgent(driver));
act(() => result.current.startPreparedReplay());
await waitFor(() => {
expect(result.current.phase).toBe("awaiting-approval");
});
unmount();
expect((observedSignal as AbortSignal | null)?.aborted).toBe(true);
});
it("denial halts the driver and clears awaiting-approval", async () => {
const driver = createFakeApprovalDriver(async () => ({ approved: false, comment: "nope" }));
const { result } = renderHook(() => useDemoAgent(driver));
+47 -25
View File
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { AgentApproval, AgentDriver, AgentMessage, PresentationToolAction } from "./events.js";
export type DemoAgentPhase = "idle" | "running" | "awaiting-approval" | "completed" | "failed";
@@ -16,62 +16,84 @@ export type DemoAgentController = {
const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> =>
message.parts.flatMap((part) => part.type === "presentation-action" ? [part.action] : []);
type PendingApproval = {
readonly resolve: (decision: AgentApproval) => void;
readonly reject: (error: Error) => void;
readonly signal: AbortSignal;
readonly abortHandler: () => void;
};
export const useDemoAgent = (driver: AgentDriver): DemoAgentController => {
const [phase, setPhase] = useState<DemoAgentPhase>("idle");
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([]);
const [pendingActions, setPendingActions] = useState<ReadonlyArray<PresentationToolAction>>([]);
const abortRef = useRef<AbortController | null>(null);
const approvalResolveRef = useRef<((decision: AgentApproval) => void) | null>(null);
const approvalRejectRef = useRef<((error: Error) => void) | null>(null);
const pendingApprovalRef = useRef<PendingApproval | null>(null);
const clearPendingApproval = useCallback((pending: PendingApproval | null) => {
if (pending === null || pendingApprovalRef.current !== pending) return;
pending.signal.removeEventListener("abort", pending.abortHandler);
pendingApprovalRef.current = null;
}, []);
const abortPendingApproval = useCallback((reason: string) => {
const pending = pendingApprovalRef.current;
if (!pending) return;
clearPendingApproval(pending);
pending.reject(new DOMException(reason, "AbortError"));
}, [clearPendingApproval]);
const reset = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
if (approvalRejectRef.current) {
approvalRejectRef.current(new DOMException("Agent reset while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
}
abortPendingApproval("Agent reset while awaiting approval");
setPhase("idle");
setMessages([]);
setPendingActions([]);
}, []);
}, [abortPendingApproval]);
const clearPendingActions = useCallback(() => {
setPendingActions([]);
}, []);
const submitApproval = useCallback((decision: AgentApproval) => {
if (approvalResolveRef.current) {
approvalResolveRef.current(decision);
approvalResolveRef.current = null;
approvalRejectRef.current = null;
setPhase("running");
}
}, []);
const pending = pendingApprovalRef.current;
if (!pending) return;
clearPendingApproval(pending);
pending.resolve(decision);
setPhase("running");
}, [clearPendingApproval]);
const requestApproval = useCallback((signal: AbortSignal): Promise<AgentApproval> => {
setPhase("awaiting-approval");
return new Promise<AgentApproval>((resolve, reject) => {
approvalResolveRef.current = resolve;
approvalRejectRef.current = reject;
if (signal.aborted) {
reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
return;
}
const onAbort = () => {
signal.removeEventListener("abort", onAbort);
const pending = pendingApprovalRef.current;
if (!pending || pending.abortHandler !== onAbort) return;
clearPendingApproval(pending);
reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
approvalResolveRef.current = null;
approvalRejectRef.current = null;
};
const pending: PendingApproval = {
resolve,
reject,
signal,
abortHandler: onAbort,
};
pendingApprovalRef.current = pending;
signal.addEventListener("abort", onAbort);
});
}, []);
}, [clearPendingApproval]);
useEffect(() => () => {
abortRef.current?.abort();
abortRef.current = null;
abortPendingApproval("Agent unmounted while awaiting approval");
}, [abortPendingApproval]);
const startPreparedReplay = useCallback(() => {
abortRef.current?.abort();
+7 -2
View File
@@ -13,6 +13,7 @@ import {
type DemoMode,
type DemoTimelineState,
} from "./timeline/reducer.js";
import type { DemoRecording } from "./timeline/models.js";
import {
executeLiveDemoStep,
failedLiveDemoEvent,
@@ -57,7 +58,7 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
export const useDemoTimeline = (
target: string | null,
recordEvidence: EvidenceRecorder,
recording?: import("./timeline/models.js").DemoRecording,
recording?: DemoRecording,
): DemoTimelineController => {
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
@@ -67,7 +68,10 @@ export const useDemoTimeline = (
const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef(recording ?? loadCanonicalDemoRecording());
const activeRecording = useRef<DemoRecording | null>(recording ?? null);
if (activeRecording.current === null) {
activeRecording.current = loadCanonicalDemoRecording();
}
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -206,6 +210,7 @@ export const useDemoTimeline = (
resetRuntime();
if (state.mode === "replay") {
const recording = activeRecording.current;
if (!recording) return;
dispatch({ type: "start", mode: "replay", events: recording.events });
} else {
dispatch({ type: "start", mode: "live", events: [] });