fix: address presentation review findings
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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: [] });
|
||||
|
||||
Reference in New Issue
Block a user