feat: add constrained presentation agent
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
agentTextMessage,
|
||||
agentToolCallPart,
|
||||
agentToolResultPart,
|
||||
approvalRequestPart,
|
||||
presentationActionPart,
|
||||
} from "./events.js";
|
||||
|
||||
describe("agent events", () => {
|
||||
it("creates a standard assistant text message", () => {
|
||||
const message = agentTextMessage("m1", "assistant", "I will use a prepared recipe.");
|
||||
expect(message).toEqual({
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "I will use a prepared recipe." }],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates tool call, tool result, and presentation action parts", () => {
|
||||
expect(agentToolCallPart("c1", "startPreparedReportRun", { deploymentId: "lda_report_case_study.default" })).toEqual({
|
||||
type: "tool-call",
|
||||
call: {
|
||||
id: "c1",
|
||||
name: "startPreparedReportRun",
|
||||
input: { deploymentId: "lda_report_case_study.default" },
|
||||
},
|
||||
});
|
||||
expect(agentToolResultPart("c1", "startPreparedReportRun", "success", { runId: "run_1" })).toEqual({
|
||||
type: "tool-result",
|
||||
result: {
|
||||
callId: "c1",
|
||||
name: "startPreparedReportRun",
|
||||
status: "success",
|
||||
output: { runId: "run_1" },
|
||||
},
|
||||
});
|
||||
expect(presentationActionPart({ type: "selectWorkflowNode", nodeId: "review_issues" })).toEqual({
|
||||
type: "presentation-action",
|
||||
action: { type: "selectWorkflowNode", nodeId: "review_issues" },
|
||||
});
|
||||
});
|
||||
|
||||
it("creates approval request part", () => {
|
||||
expect(approvalRequestPart("c1", "resumeIssueReview", "Approve the typed issue review?")).toEqual({
|
||||
type: "approval-request",
|
||||
callId: "c1",
|
||||
name: "resumeIssueReview",
|
||||
prompt: "Approve the typed issue review?",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { AgentToolName } from "./tools.js";
|
||||
|
||||
export type AgentRole = "user" | "assistant";
|
||||
|
||||
export type PresentationToolAction =
|
||||
| { readonly type: "selectWorkflowNode"; readonly nodeId: string }
|
||||
| { readonly type: "focusOperation"; readonly eventId: string }
|
||||
| { readonly type: "openEvidence"; readonly eventId: string }
|
||||
| { readonly type: "showTraceFrame"; readonly frameIndex: number }
|
||||
| { readonly type: "setBeat"; readonly beatId: string };
|
||||
|
||||
export type AgentToolCall = {
|
||||
readonly id: string;
|
||||
readonly name: AgentToolName;
|
||||
readonly input: unknown;
|
||||
};
|
||||
|
||||
export type AgentToolResult = {
|
||||
readonly callId: string;
|
||||
readonly name: AgentToolName;
|
||||
readonly status: "success" | "failure";
|
||||
readonly output: unknown;
|
||||
};
|
||||
|
||||
export type AgentMessagePart =
|
||||
| { readonly type: "text"; readonly text: string }
|
||||
| { readonly type: "tool-call"; readonly call: AgentToolCall }
|
||||
| { readonly type: "tool-result"; readonly result: AgentToolResult }
|
||||
| { readonly type: "presentation-action"; readonly action: PresentationToolAction }
|
||||
| { readonly type: "approval-request"; readonly callId: string; readonly name: AgentToolName; readonly prompt: string }
|
||||
| { readonly type: "error"; readonly message: string };
|
||||
|
||||
export type AgentMessage = {
|
||||
readonly id: string;
|
||||
readonly role: AgentRole;
|
||||
readonly parts: ReadonlyArray<AgentMessagePart>;
|
||||
};
|
||||
|
||||
export const agentTextMessage = (id: string, role: AgentRole, text: string): AgentMessage => ({
|
||||
id,
|
||||
role,
|
||||
parts: [{ type: "text", text }],
|
||||
});
|
||||
|
||||
export const agentToolCallPart = (
|
||||
id: string,
|
||||
name: AgentToolName,
|
||||
input: unknown,
|
||||
): AgentMessagePart => ({
|
||||
type: "tool-call",
|
||||
call: { id, name, input },
|
||||
});
|
||||
|
||||
export const agentToolResultPart = (
|
||||
callId: string,
|
||||
name: AgentToolName,
|
||||
status: "success" | "failure",
|
||||
output: unknown,
|
||||
): AgentMessagePart => ({
|
||||
type: "tool-result",
|
||||
result: { callId, name, status, output },
|
||||
});
|
||||
|
||||
export const presentationActionPart = (action: PresentationToolAction): AgentMessagePart => ({
|
||||
type: "presentation-action",
|
||||
action,
|
||||
});
|
||||
|
||||
export const approvalRequestPart = (
|
||||
callId: string,
|
||||
name: AgentToolName,
|
||||
prompt: string,
|
||||
): AgentMessagePart => ({
|
||||
type: "approval-request",
|
||||
callId,
|
||||
name,
|
||||
prompt,
|
||||
});
|
||||
|
||||
export type AgentApproval = {
|
||||
readonly approved: boolean;
|
||||
readonly comment: string;
|
||||
};
|
||||
|
||||
export type AgentRunInput = {
|
||||
readonly target?: string | null;
|
||||
};
|
||||
|
||||
export type AgentDriverKind = "prepared-recipe" | "ai-sdk";
|
||||
|
||||
export type AgentDriver = {
|
||||
readonly kind: AgentDriverKind;
|
||||
readonly run: (
|
||||
input: AgentRunInput,
|
||||
signal: AbortSignal,
|
||||
requestApproval: (signal: AbortSignal) => Promise<AgentApproval>,
|
||||
) => AsyncIterable<AgentMessage>;
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalDemoRecording } from "../timeline/replay.js";
|
||||
import { runPreparedRecipeReplay } from "./preparedRecipeDriver.js";
|
||||
|
||||
const collect = async <T>(events: AsyncIterable<T>): Promise<ReadonlyArray<T>> => {
|
||||
const collected: T[] = [];
|
||||
for await (const event of events) collected.push(event);
|
||||
return collected;
|
||||
};
|
||||
|
||||
describe("prepared recipe driver", () => {
|
||||
it("emits a standard chat sequence for the replay recipe", async () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
const signal = new AbortController().signal;
|
||||
const messages = await collect(runPreparedRecipeReplay(recording, signal, async () => ({ approved: true, comment: "test" })));
|
||||
expect(messages[0]?.role).toBe("user");
|
||||
expect(messages.some((message) =>
|
||||
message.parts.some((part) => part.type === "tool-call" && part.call.name === "startPreparedReportRun"),
|
||||
)).toBe(true);
|
||||
expect(messages.some((message) =>
|
||||
message.parts.some((part) => part.type === "presentation-action" && part.action.type === "selectWorkflowNode"),
|
||||
)).toBe(true);
|
||||
expect(messages.at(-1)?.parts.some((part) =>
|
||||
part.type === "text" && part.text.includes("run evidence"),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not emit unknown tool calls", async () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
const signal = new AbortController().signal;
|
||||
const messages = await collect(runPreparedRecipeReplay(recording, signal, async () => ({ approved: true, comment: "test" })));
|
||||
const toolNames = messages.flatMap((message) =>
|
||||
message.parts.flatMap((part) => part.type === "tool-call" ? [part.call.name] : []),
|
||||
);
|
||||
expect(toolNames).toEqual([
|
||||
"inspectDeployment",
|
||||
"startPreparedReportRun",
|
||||
"selectWorkflowNode",
|
||||
"resumeIssueReview",
|
||||
"readRunTrace",
|
||||
"openEvidence",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits approval-request at resumeIssueReview and waits for decision", async () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
const controller = new AbortController();
|
||||
const approvals: Array<{ approved: boolean; comment: string }> = [];
|
||||
const requestApproval = async (signal: AbortSignal) => {
|
||||
const decision = { approved: true, comment: "operator approved" };
|
||||
approvals.push(decision);
|
||||
return decision;
|
||||
};
|
||||
const messages = await collect(runPreparedRecipeReplay(recording, controller.signal, requestApproval));
|
||||
const approvalMessages = messages.filter((m) =>
|
||||
m.parts.some((p) => p.type === "approval-request"),
|
||||
);
|
||||
expect(approvalMessages.length).toBe(1);
|
||||
expect(approvalMessages[0]!.parts.some((p) =>
|
||||
p.type === "approval-request" && p.name === "resumeIssueReview",
|
||||
)).toBe(true);
|
||||
expect(approvals.length).toBe(1);
|
||||
expect(approvals[0]!.approved).toBe(true);
|
||||
expect(messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "tool-call" && p.call.name === "readRunTrace"),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("stops after denial and does not emit resume result, trace, or evidence", async () => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
const controller = new AbortController();
|
||||
const messages = await collect(runPreparedRecipeReplay(
|
||||
recording,
|
||||
controller.signal,
|
||||
async () => ({ approved: false, comment: "Not now" }),
|
||||
));
|
||||
const toolCalls = messages.flatMap((m) =>
|
||||
m.parts.flatMap((p) => p.type === "tool-call" ? [p.call.name] : []),
|
||||
);
|
||||
expect(toolCalls).toEqual([
|
||||
"inspectDeployment",
|
||||
"startPreparedReportRun",
|
||||
"selectWorkflowNode",
|
||||
"resumeIssueReview",
|
||||
]);
|
||||
expect(messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "tool-result" && p.result.name === "resumeIssueReview" && p.result.status === "success" && (p.result.output as { outcome: string }).outcome === "cancelled"),
|
||||
)).toBe(true);
|
||||
expect(messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "text" && p.text.includes("cancelled")),
|
||||
)).toBe(true);
|
||||
expect(messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "text" && p.text.includes("run evidence")),
|
||||
)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { DemoRecording } from "../timeline/models.js";
|
||||
import {
|
||||
agentTextMessage,
|
||||
agentToolCallPart,
|
||||
agentToolResultPart,
|
||||
approvalRequestPart,
|
||||
presentationActionPart,
|
||||
type AgentApproval,
|
||||
type AgentDriver,
|
||||
type AgentMessage,
|
||||
type AgentMessagePart,
|
||||
} from "./events.js";
|
||||
import { PREPARE_THESIS_REPORT_RECIPE, type RecipeTool } from "./recipes.js";
|
||||
|
||||
export const assertNever = (value: never): never => {
|
||||
throw new Error(`Unhandled case: ${String(value)}`);
|
||||
};
|
||||
|
||||
const emitToolStep = (
|
||||
stepId: string,
|
||||
toolName: RecipeTool,
|
||||
input: unknown,
|
||||
output: unknown,
|
||||
): AgentMessage => ({
|
||||
id: stepId,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
agentToolCallPart(`${stepId}-call`, toolName, input),
|
||||
agentToolResultPart(`${stepId}-call`, toolName, "success", output),
|
||||
],
|
||||
});
|
||||
|
||||
export async function* runPreparedRecipeReplay(
|
||||
recording: DemoRecording,
|
||||
signal: AbortSignal,
|
||||
requestApproval: (signal: AbortSignal) => Promise<AgentApproval>,
|
||||
): AsyncIterable<AgentMessage> {
|
||||
const recipe = PREPARE_THESIS_REPORT_RECIPE;
|
||||
const deploymentId = recipe.deploymentId;
|
||||
const runStart = recording.events.find((event) => event.stage === "run_start");
|
||||
const resume = recording.events.find((event) => event.stage === "run_resume");
|
||||
const trace = recording.events.find((event) => event.stage === "trace_read");
|
||||
const runId = runStart?.resultingIds.runId ?? "recorded-run";
|
||||
|
||||
yield agentTextMessage("recipe-user", "user", recipe.userPrompt);
|
||||
|
||||
for (const step of recipe.steps) {
|
||||
if (step.toolName === null) {
|
||||
yield agentTextMessage(`step-${step.id}`, "assistant", step.narration);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (step.toolName) {
|
||||
case "inspectDeployment":
|
||||
yield emitToolStep(step.id, step.toolName, { deploymentId }, { deploymentId });
|
||||
break;
|
||||
case "startPreparedReportRun":
|
||||
yield emitToolStep(step.id, step.toolName, { deploymentId }, {
|
||||
runId,
|
||||
eventId: runStart?.id ?? null,
|
||||
});
|
||||
break;
|
||||
case "selectWorkflowNode":
|
||||
yield {
|
||||
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" }),
|
||||
],
|
||||
};
|
||||
break;
|
||||
case "resumeIssueReview": {
|
||||
const callId = `${step.id}-call`;
|
||||
const approvalParts: AgentMessagePart[] = [
|
||||
agentToolCallPart(callId, step.toolName, { runId }),
|
||||
approvalRequestPart(callId, step.toolName, "Approve resuming the workflow run with the selected issues?"),
|
||||
];
|
||||
yield { id: step.id, role: "assistant", parts: approvalParts };
|
||||
const decision = await requestApproval(signal);
|
||||
if (!decision.approved) {
|
||||
yield {
|
||||
id: `${step.id}-cancelled`,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
agentToolResultPart(callId, step.toolName, "success", {
|
||||
outcome: "cancelled",
|
||||
runId,
|
||||
comment: decision.comment,
|
||||
}),
|
||||
{ type: "text", text: `Operator cancelled the resume: ${decision.comment}` },
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
id: `${step.id}-result`,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
agentToolResultPart(callId, step.toolName, "success", {
|
||||
runId,
|
||||
eventId: resume?.id ?? null,
|
||||
}),
|
||||
],
|
||||
};
|
||||
break;
|
||||
}
|
||||
case "readRunTrace":
|
||||
yield emitToolStep(step.id, step.toolName, { runId }, {
|
||||
runId,
|
||||
eventId: trace?.id ?? null,
|
||||
});
|
||||
break;
|
||||
case "openEvidence":
|
||||
yield {
|
||||
id: step.id,
|
||||
role: "assistant",
|
||||
parts: [
|
||||
agentToolCallPart(`${step.id}-call`, step.toolName, { eventId: trace?.id ?? "trace" }),
|
||||
presentationActionPart({ type: "openEvidence", eventId: trace?.id ?? "trace" }),
|
||||
agentToolResultPart(`${step.id}-call`, step.toolName, "success", { eventId: trace?.id ?? "trace" }),
|
||||
],
|
||||
};
|
||||
break;
|
||||
default:
|
||||
assertNever(step.toolName);
|
||||
}
|
||||
}
|
||||
|
||||
yield agentTextMessage(
|
||||
"summary",
|
||||
"assistant",
|
||||
`The prepared recipe completed with run evidence for ${runId}.`,
|
||||
);
|
||||
}
|
||||
|
||||
export const createPreparedRecipeDriver = (
|
||||
recording: DemoRecording,
|
||||
): AgentDriver => ({
|
||||
kind: "prepared-recipe",
|
||||
run: (input, signal, requestApproval) => runPreparedRecipeReplay(recording, signal, requestApproval),
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LDA_REPORT_DEPLOYMENT_ID } from "../ldaReportDemoConfig.js";
|
||||
|
||||
export type RecipeTool =
|
||||
| "inspectDeployment"
|
||||
| "startPreparedReportRun"
|
||||
| "selectWorkflowNode"
|
||||
| "resumeIssueReview"
|
||||
| "readRunTrace"
|
||||
| "openEvidence";
|
||||
|
||||
export type PreparedRecipeStep = {
|
||||
readonly id: string;
|
||||
readonly narration: string;
|
||||
readonly toolName: RecipeTool | null;
|
||||
};
|
||||
|
||||
export type PreparedRecipe = {
|
||||
readonly id: "prepare-thesis-report";
|
||||
readonly title: string;
|
||||
readonly userPrompt: string;
|
||||
readonly deploymentId: string;
|
||||
readonly steps: ReadonlyArray<PreparedRecipeStep>;
|
||||
};
|
||||
|
||||
export const PREPARE_THESIS_REPORT_RECIPE: PreparedRecipe = {
|
||||
id: "prepare-thesis-report",
|
||||
title: "Prepare thesis readiness report",
|
||||
userPrompt: "Prepare the thesis readiness report.",
|
||||
deploymentId: LDA_REPORT_DEPLOYMENT_ID,
|
||||
steps: [
|
||||
{
|
||||
id: "select-recipe",
|
||||
narration: "I found a prepared workflow recipe for the thesis readiness report.",
|
||||
toolName: null,
|
||||
},
|
||||
{
|
||||
id: "inspect-deployment",
|
||||
narration: "I will inspect the prepared deployment before starting a run.",
|
||||
toolName: "inspectDeployment",
|
||||
},
|
||||
{
|
||||
id: "start-run",
|
||||
narration: "I will start the prepared workflow run.",
|
||||
toolName: "startPreparedReportRun",
|
||||
},
|
||||
{
|
||||
id: "focus-interrupt",
|
||||
narration: "Let's zoom into the typed issue-review interrupt.",
|
||||
toolName: "selectWorkflowNode",
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
narration: "I will resume the run with the selected issues.",
|
||||
toolName: "resumeIssueReview",
|
||||
},
|
||||
{
|
||||
id: "trace",
|
||||
narration: "I will read the run trace as evidence.",
|
||||
toolName: "readRunTrace",
|
||||
},
|
||||
{
|
||||
id: "open-evidence",
|
||||
narration: "I will open the evidence linked to the trace call.",
|
||||
toolName: "openEvidence",
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_TOOLS, isAllowedAgentToolName } from "./tools.js";
|
||||
|
||||
describe("agent tools", () => {
|
||||
it("separates workflow tools from presentation tools", () => {
|
||||
expect(AGENT_TOOLS.inspectDeployment.kind).toBe("workflow");
|
||||
expect(AGENT_TOOLS.startPreparedReportRun.kind).toBe("workflow");
|
||||
expect(AGENT_TOOLS.resumeIssueReview.kind).toBe("workflow");
|
||||
expect(AGENT_TOOLS.readRunTrace.kind).toBe("workflow");
|
||||
expect(AGENT_TOOLS.selectWorkflowNode.kind).toBe("presentation");
|
||||
expect(AGENT_TOOLS.openEvidence.kind).toBe("presentation");
|
||||
});
|
||||
|
||||
it("rejects unknown tool names", () => {
|
||||
expect(isAllowedAgentToolName("selectWorkflowNode")).toBe(true);
|
||||
expect(isAllowedAgentToolName("readFile")).toBe(false);
|
||||
expect(isAllowedAgentToolName("authorArbitraryWorkflow")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
export type WorkflowToolName =
|
||||
| "inspectDeployment"
|
||||
| "startPreparedReportRun"
|
||||
| "resumeIssueReview"
|
||||
| "readRunTrace";
|
||||
|
||||
export type PresentationToolName =
|
||||
| "selectWorkflowNode"
|
||||
| "focusOperation"
|
||||
| "openEvidence"
|
||||
| "showTraceFrame"
|
||||
| "setBeat";
|
||||
|
||||
export type AgentToolName = WorkflowToolName | PresentationToolName;
|
||||
|
||||
export type AgentToolDescriptor = {
|
||||
readonly name: AgentToolName;
|
||||
readonly kind: "workflow" | "presentation";
|
||||
readonly description: string;
|
||||
};
|
||||
|
||||
export const AGENT_TOOLS = {
|
||||
inspectDeployment: {
|
||||
name: "inspectDeployment",
|
||||
kind: "workflow",
|
||||
description: "Inspect the prepared report deployment.",
|
||||
},
|
||||
startPreparedReportRun: {
|
||||
name: "startPreparedReportRun",
|
||||
kind: "workflow",
|
||||
description: "Start the prepared report workflow run.",
|
||||
},
|
||||
resumeIssueReview: {
|
||||
name: "resumeIssueReview",
|
||||
kind: "workflow",
|
||||
description: "Resume the typed issue-review interrupt.",
|
||||
},
|
||||
readRunTrace: {
|
||||
name: "readRunTrace",
|
||||
kind: "workflow",
|
||||
description: "Read trace frames for the completed report run.",
|
||||
},
|
||||
selectWorkflowNode: {
|
||||
name: "selectWorkflowNode",
|
||||
kind: "presentation",
|
||||
description: "Focus a workflow graph node in the presentation.",
|
||||
},
|
||||
focusOperation: {
|
||||
name: "focusOperation",
|
||||
kind: "presentation",
|
||||
description: "Focus an operation event in the presentation.",
|
||||
},
|
||||
openEvidence: {
|
||||
name: "openEvidence",
|
||||
kind: "presentation",
|
||||
description: "Open evidence for an operation event.",
|
||||
},
|
||||
showTraceFrame: {
|
||||
name: "showTraceFrame",
|
||||
kind: "presentation",
|
||||
description: "Focus a trace frame in the presentation.",
|
||||
},
|
||||
setBeat: {
|
||||
name: "setBeat",
|
||||
kind: "presentation",
|
||||
description: "Move the presentation to a named beat.",
|
||||
},
|
||||
} satisfies Record<AgentToolName, AgentToolDescriptor>;
|
||||
|
||||
export const isAllowedAgentToolName = (name: string): name is AgentToolName =>
|
||||
Object.hasOwn(AGENT_TOOLS, name);
|
||||
@@ -0,0 +1,144 @@
|
||||
import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { loadCanonicalDemoRecording } from "../timeline/replay.js";
|
||||
import { createPreparedRecipeDriver } from "./preparedRecipeDriver.js";
|
||||
import { useDemoAgent } from "./useDemoAgent.js";
|
||||
import type { AgentApproval, AgentDriver, AgentMessage } from "./events.js";
|
||||
|
||||
const createAutoApprovingDriver = (): AgentDriver => {
|
||||
const recording = loadCanonicalDemoRecording();
|
||||
const base = createPreparedRecipeDriver(recording);
|
||||
return {
|
||||
...base,
|
||||
run: (input, signal, _requestApproval) => base.run(input, signal, async () => ({ approved: true, comment: "auto" })),
|
||||
};
|
||||
};
|
||||
|
||||
const createFakeApprovalDriver = (
|
||||
onApproval: (signal: AbortSignal) => Promise<AgentApproval>,
|
||||
): AgentDriver => ({
|
||||
kind: "prepared-recipe",
|
||||
run: async function* (_input, signal, requestApproval) {
|
||||
yield { id: "user-msg", role: "user", parts: [{ type: "text", text: "Do something" }] };
|
||||
const callId = "fake-call";
|
||||
yield {
|
||||
id: "approval-msg",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "tool-call", call: { id: callId, name: "resumeIssueReview", input: { runId: "r1" } } },
|
||||
{ type: "approval-request", callId, name: "resumeIssueReview", prompt: "Approve resume?" },
|
||||
],
|
||||
};
|
||||
const decision = await requestApproval(signal);
|
||||
if (!decision.approved) {
|
||||
yield {
|
||||
id: "cancelled-msg",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "tool-result", result: { callId, name: "resumeIssueReview", status: "success", output: { outcome: "cancelled", runId: "r1", comment: decision.comment } } },
|
||||
{ type: "text", text: `Operator cancelled the resume: ${decision.comment}` },
|
||||
],
|
||||
};
|
||||
return;
|
||||
}
|
||||
yield {
|
||||
id: "result-msg",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-result", result: { callId, name: "resumeIssueReview", status: "success", output: { runId: "r1" } } }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
describe("useDemoAgent", () => {
|
||||
it("runs the prepared replay recipe and collects messages", async () => {
|
||||
const { result } = renderHook(() => useDemoAgent(createAutoApprovingDriver()));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.messages.length).toBeGreaterThan(3);
|
||||
});
|
||||
expect(result.current.phase).toBe("completed");
|
||||
expect(result.current.messages[0]?.role).toBe("user");
|
||||
});
|
||||
|
||||
it("records presentation actions from the recipe", async () => {
|
||||
const { result } = renderHook(() => useDemoAgent(createAutoApprovingDriver()));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.pendingActions).toContainEqual({ type: "selectWorkflowNode", nodeId: "review_issues" });
|
||||
});
|
||||
});
|
||||
|
||||
it("reset clears messages and actions", async () => {
|
||||
const { result } = renderHook(() => useDemoAgent(createAutoApprovingDriver()));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("completed");
|
||||
});
|
||||
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.pendingActions).toEqual([]);
|
||||
expect(result.current.phase).toBe("idle");
|
||||
});
|
||||
|
||||
it("pauses at approval-request and resumes after submitApproval", async () => {
|
||||
const driver = createFakeApprovalDriver(async () => ({ approved: true, comment: "ok" }));
|
||||
const { result } = renderHook(() => useDemoAgent(driver));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("awaiting-approval");
|
||||
});
|
||||
expect(result.current.messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "approval-request"),
|
||||
)).toBe(true);
|
||||
|
||||
act(() => result.current.submitApproval({ approved: true, comment: "ok" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("completed");
|
||||
});
|
||||
expect(result.current.messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "tool-result" && p.result.status === "success"),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it("reset rejects pending approval and clears state", async () => {
|
||||
const driver = createFakeApprovalDriver(async () => ({ approved: true, comment: "ok" }));
|
||||
const { result } = renderHook(() => useDemoAgent(driver));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("awaiting-approval");
|
||||
});
|
||||
|
||||
act(() => result.current.reset());
|
||||
|
||||
expect(result.current.phase).toBe("idle");
|
||||
expect(result.current.messages).toEqual([]);
|
||||
expect(result.current.pendingActions).toEqual([]);
|
||||
});
|
||||
|
||||
it("denial halts the driver and clears awaiting-approval", async () => {
|
||||
const driver = createFakeApprovalDriver(async () => ({ approved: false, comment: "nope" }));
|
||||
const { result } = renderHook(() => useDemoAgent(driver));
|
||||
act(() => result.current.startPreparedReplay());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("awaiting-approval");
|
||||
});
|
||||
|
||||
act(() => result.current.submitApproval({ approved: false, comment: "nope" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.phase).toBe("completed");
|
||||
});
|
||||
expect(result.current.messages.some((m) =>
|
||||
m.parts.some((p) => p.type === "text" && p.text.includes("cancelled")),
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import type { AgentApproval, AgentDriver, AgentMessage, PresentationToolAction } from "./events.js";
|
||||
|
||||
export type DemoAgentPhase = "idle" | "running" | "awaiting-approval" | "completed" | "failed";
|
||||
|
||||
export type DemoAgentController = {
|
||||
readonly phase: DemoAgentPhase;
|
||||
readonly messages: ReadonlyArray<AgentMessage>;
|
||||
readonly pendingActions: ReadonlyArray<PresentationToolAction>;
|
||||
readonly startPreparedReplay: () => void;
|
||||
readonly submitApproval: (decision: AgentApproval) => void;
|
||||
readonly clearPendingActions: () => void;
|
||||
readonly reset: () => void;
|
||||
};
|
||||
|
||||
const collectActions = (message: AgentMessage): ReadonlyArray<PresentationToolAction> =>
|
||||
message.parts.flatMap((part) => part.type === "presentation-action" ? [part.action] : []);
|
||||
|
||||
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 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;
|
||||
}
|
||||
setPhase("idle");
|
||||
setMessages([]);
|
||||
setPendingActions([]);
|
||||
}, []);
|
||||
|
||||
const clearPendingActions = useCallback(() => {
|
||||
setPendingActions([]);
|
||||
}, []);
|
||||
|
||||
const submitApproval = useCallback((decision: AgentApproval) => {
|
||||
if (approvalResolveRef.current) {
|
||||
approvalResolveRef.current(decision);
|
||||
approvalResolveRef.current = null;
|
||||
approvalRejectRef.current = null;
|
||||
setPhase("running");
|
||||
}
|
||||
}, []);
|
||||
|
||||
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);
|
||||
reject(new DOMException("Agent aborted while awaiting approval", "AbortError"));
|
||||
approvalResolveRef.current = null;
|
||||
approvalRejectRef.current = null;
|
||||
};
|
||||
signal.addEventListener("abort", onAbort);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startPreparedReplay = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
setPhase("running");
|
||||
setMessages([]);
|
||||
setPendingActions([]);
|
||||
|
||||
const drive = async () => {
|
||||
try {
|
||||
for await (const message of driver.run({ target: null }, controller.signal, (signal) => requestApproval(signal))) {
|
||||
if (controller.signal.aborted) break;
|
||||
setMessages((current) => [...current, message]);
|
||||
const actions = collectActions(message);
|
||||
if (actions.length > 0) {
|
||||
setPendingActions((current) => [...current, ...actions]);
|
||||
}
|
||||
}
|
||||
if (!controller.signal.aborted) {
|
||||
setPhase("completed");
|
||||
}
|
||||
} catch (error) {
|
||||
if (!controller.signal.aborted) {
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ id: "agent-failure", role: "assistant", parts: [{ type: "error", message: msg }] },
|
||||
]);
|
||||
setPhase("failed");
|
||||
}
|
||||
}
|
||||
};
|
||||
void drive();
|
||||
}, [driver, requestApproval]);
|
||||
|
||||
return {
|
||||
phase,
|
||||
messages,
|
||||
pendingActions,
|
||||
startPreparedReplay,
|
||||
submitApproval,
|
||||
clearPendingActions,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -57,6 +57,7 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
|
||||
export const useDemoTimeline = (
|
||||
target: string | null,
|
||||
recordEvidence: EvidenceRecorder,
|
||||
recording?: import("./timeline/models.js").DemoRecording,
|
||||
): DemoTimelineController => {
|
||||
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
|
||||
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
|
||||
@@ -66,7 +67,7 @@ export const useDemoTimeline = (
|
||||
const generationRef = useRef(0);
|
||||
const [inFlight, setInFlight] = useState(false);
|
||||
const approvalRef = useRef<DemoApproval | null>(null);
|
||||
const activeRecording = useRef(loadCanonicalDemoRecording());
|
||||
const activeRecording = useRef(recording ?? loadCanonicalDemoRecording());
|
||||
|
||||
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
|
||||
const [output, setOutput] = useState<LdaReportOutput | null>(null);
|
||||
|
||||
Reference in New Issue
Block a user