feat: add constrained presentation agent

This commit is contained in:
lda
2026-07-04 06:01:13 +07:00 Verified
parent 50d29f5144
commit da01d5624a
20 changed files with 1100 additions and 40 deletions
+6 -1
View File
@@ -74,8 +74,13 @@ Implementation order:
[`React presentation mode`](superpowers/specs/2026-07-03-react-presentation-mode-design.md). [`React presentation mode`](superpowers/specs/2026-07-03-react-presentation-mode-design.md).
Implementation: Implementation:
[`React presentation mode plan`](historical/superpowers/plans/2026-07-03-react-presentation-mode.md). [`React presentation mode plan`](historical/superpowers/plans/2026-07-03-react-presentation-mode.md).
8. Add a constrained demo agent that invokes one prepared recipe macro. Design: 8. Completed: constrained demo agent that invokes one prepared recipe macro.
Live mode is deferred to a future slice; the prepared driver is replay-only
for now.
Design:
[`constrained demo agent`](superpowers/specs/2026-07-03-constrained-demo-agent-design.md). [`constrained demo agent`](superpowers/specs/2026-07-03-constrained-demo-agent-design.md).
Implementation:
[`constrained demo agent plan`](historical/superpowers/plans/2026-07-03-constrained-demo-agent.md).
9. Add a static slide/appendix shell only after presentation mode is clear. 9. 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.
@@ -65,22 +65,23 @@ driver can feed the same standard chat/presentation UI.
```ts ```ts
type AgentDriverKind = "prepared-recipe" | "ai-sdk"; type AgentDriverKind = "prepared-recipe" | "ai-sdk";
type AgentMessageEvent = type AgentApproval = { readonly approved: boolean; readonly comment: string };
| AgentTextEvent
| AgentToolCallEvent
| AgentToolResultEvent
| AgentApprovalRequestEvent
| AgentFailureEvent;
type AgentDriver = { type AgentDriver = {
readonly kind: AgentDriverKind; readonly kind: AgentDriverKind;
readonly start: (input: AgentRunInput) => AsyncIterable<AgentMessageEvent>; readonly run: (
input: AgentRunInput,
signal: AbortSignal,
requestApproval: () => Promise<AgentApproval>,
) => AsyncIterable<AgentMessage>;
}; };
``` ```
The first implementation only ships the `prepared-recipe` driver. It produces The first implementation only ships the `prepared-recipe` driver. It produces
events from a hard-coded recipe, calls the existing demo timeline execution events from a hard-coded recipe, calls the existing demo timeline execution
path, and emits presentation actions through the same event stream. path, and emits presentation actions through the same event stream. The
prepared driver ignores the `requestApproval` callback (deterministic, no
approval needed).
The `ai-sdk` kind is reserved for a future driver. The boundary should be shaped The `ai-sdk` kind is reserved for a future driver. The boundary should be shaped
so a server-side Vercel AI SDK integration can map `streamText` parts into the so a server-side Vercel AI SDK integration can map `streamText` parts into the
@@ -244,18 +245,20 @@ driver composition.
PresentationRoute PresentationRoute
-> useDemoAgent(driver = preparedRecipe) -> useDemoAgent(driver = preparedRecipe)
-> PreparedRecipeDriver -> PreparedRecipeDriver
-> WorkflowToolAdapter -> existing demo timeline live/replay operation path
-> PresentationToolAdapter -> selected node / beat / evidence state -> PresentationToolAdapter -> selected node / beat / evidence state
-> AgentMessageEvent[] -> AgentMessage[]
-> StandardChat / OperationBlock / EvidenceDrawer / DemoTimeline -> StandardChat / OperationBlock / EvidenceDrawer / DemoTimeline
``` ```
In replay mode, the driver reads from the reviewed recording and emits the The prepared driver reads from the reviewed recording and emits agent events
matching agent events without a server. without a server. It passes operation evidence to the hook, which forwards it
to the evidence drawer.
In live mode, the driver calls the connected workflow server through the In live mode, the driver calls the connected workflow server through the
existing RPC operation path. Live mode remains explicit and disabled without a existing RPC operation path. Live mode is deferred to a future slice; the
target. prepared driver is replay-only for now. The `AgentDriver` interface includes
`requestApproval` for future live-mode approval, but the prepared driver
currently ignores it.
## Future AI SDK Path ## Future AI SDK Path
@@ -328,7 +331,6 @@ Unit tests should cover:
- prepared driver cannot emit operations outside the recipe; - prepared driver cannot emit operations outside the recipe;
- prepared driver cannot emit presentation actions outside the allowed tool set; - prepared driver cannot emit presentation actions outside the allowed tool set;
- replay mode produces agent events without a target; - replay mode produces agent events without a target;
- live mode delegates to the operation path in the expected order;
- presentation tool events select the expected workflow node or evidence target; - presentation tool events select the expected workflow node or evidence target;
- approval event pauses until selected issues are submitted or cancelled; - approval event pauses until selected issues are submitted or cancelled;
- reset/start clears stale messages, approvals, outputs, and in-flight work; - reset/start clears stale messages, approvals, outputs, and in-flight work;
@@ -351,7 +353,7 @@ The slice is complete when:
1. presentation chat is driven by agent events instead of fixed static copy; 1. presentation chat is driven by agent events instead of fixed static copy;
2. the prepared recipe can run fully in replay mode without a server; 2. the prepared recipe can run fully in replay mode without a server;
3. live mode uses the existing workflow operation path and remains explicit; 3. live mode is deferred; the prepared driver is replay-only;
4. tool-call events map to operation blocks and evidence records; 4. tool-call events map to operation blocks and evidence records;
5. presentation tool events can focus the graph/evidence stage; 5. presentation tool events can focus the graph/evidence stage;
6. the code has a named driver seam for a future AI SDK implementation; 6. the code has a named driver seam for a future AI SDK implementation;
@@ -368,3 +370,5 @@ The slice is complete when:
- Persisting agent sessions. - Persisting agent sessions.
- Re-styling the presentation. - Re-styling the presentation.
- Replacing the lifecycle explorer. - Replacing the lifecycle explorer.
- Live mode: the prepared driver is replay-only for now. Live mode uses the
existing workflow operation path and remains explicit.
+16
View File
@@ -177,3 +177,19 @@ navigation, operation blocks, graph node spotlight, and evidence drawer.
pnpm --dir web dev pnpm --dir web dev
# open http://127.0.0.1:5173/present # open http://127.0.0.1:5173/present
``` ```
### Constrained Demo Agent
`/present` includes a prepared agent recipe for the thesis readiness report.
The recipe is deterministic: it emits standard chat message parts, workflow tool
calls, and presentation tool actions without requiring a model provider key.
The current prepared recipe can:
- identify the `lda_report_case_study.default` workflow deployment;
- show tool calls for run start, resume, and trace read;
- focus the `review_issues` interrupt node through a presentation tool action;
- open evidence linked to the run trace.
This is intentionally not a general autonomous planner. A future server-side
Vercel AI SDK driver can feed the same message-part interface.
@@ -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?",
});
});
});
+98
View File
@@ -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);
});
});
+71
View File
@@ -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,
};
};
+2 -1
View File
@@ -57,6 +57,7 @@ const deriveMissingMessage = (mode: DemoMode, target: string | null): string | n
export const useDemoTimeline = ( export const useDemoTimeline = (
target: string | null, target: string | null,
recordEvidence: EvidenceRecorder, recordEvidence: EvidenceRecorder,
recording?: import("./timeline/models.js").DemoRecording,
): DemoTimelineController => { ): DemoTimelineController => {
const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState); const [state, dispatch] = useReducer(demoTimelineReducer, initialDemoTimelineState);
const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext); const liveContextRef = useRef<LiveDemoContext>(initialLiveDemoContext);
@@ -66,7 +67,7 @@ export const useDemoTimeline = (
const generationRef = useRef(0); const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false); const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null); const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef(loadCanonicalDemoRecording()); const activeRecording = useRef(recording ?? loadCanonicalDemoRecording());
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null); const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null); const [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { OperatorChat } from "./OperatorChat.js";
import type { PresentationState } from "./presentation-state.js";
import type { AgentMessage } from "../demo/agent/events.js";
const state: PresentationState = {
beat: "intro",
selectedNodeId: null,
chatMode: "full",
evidenceMode: "hidden",
playbackMode: "replay",
};
describe("OperatorChat", () => {
it("renders standard agent message parts", () => {
const messages: ReadonlyArray<AgentMessage> = [
{ id: "u1", role: "user", parts: [{ type: "text", text: "Prepare the report." }] },
{
id: "a1",
role: "assistant",
parts: [
{ type: "text", text: "I will use the prepared recipe." },
{
type: "tool-call",
call: { id: "call-1", name: "selectWorkflowNode", input: { nodeId: "review_issues" } },
},
{
type: "tool-result",
result: { callId: "call-1", name: "selectWorkflowNode", status: "success", output: { nodeId: "review_issues" } },
},
],
},
];
render(<OperatorChat state={state} messages={messages} />);
expect(screen.getByText("Prepare the report.")).toBeInTheDocument();
expect(screen.getByText("I will use the prepared recipe.")).toBeInTheDocument();
expect(screen.getByText(/tool call/i)).toBeInTheDocument();
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBe(2);
expect(screen.getByText(/tool result/i)).toBeInTheDocument();
});
});
@@ -1,26 +1,91 @@
import { PREPARE_THESIS_REPORT_RECIPE } from "../demo/agent/recipes.js";
import type { AgentMessage, AgentMessagePart } from "../demo/agent/events.js";
import type { PresentationState } from "./presentation-state.js"; import type { PresentationState } from "./presentation-state.js";
type OperatorChatProps = { type OperatorChatProps = {
readonly state: PresentationState; readonly state: PresentationState;
readonly messages?: ReadonlyArray<AgentMessage> | undefined;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
}; };
export const OperatorChat = ({ state }: OperatorChatProps) => ( const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat"> { id: "fallback-user", role: "user", parts: [{ type: "text", text: PREPARE_THESIS_REPORT_RECIPE.userPrompt }] },
<div className="chat-message chat-message--operator"> {
<strong>Operator</strong> id: "fallback-system",
<p>Prepare the thesis readiness report.</p> role: "assistant",
</div> parts: [
<div className="chat-message chat-message--system"> { type: "text", text: `Found prepared workflow recipe: ${PREPARE_THESIS_REPORT_RECIPE.id}.` },
<strong>lda.chat</strong> {
<p>Found prepared workflow recipe: <code>lda_report_case_study</code>.</p> type: "text",
</div> text: state.playbackMode === "replay"
<div className="chat-message chat-message--system">
<strong>lda.chat</strong>
<p>
{state.playbackMode === "replay"
? "Replay mode is active. Live execution is available when connected." ? "Replay mode is active. Live execution is available when connected."
: "Live execution is active. Operations are being sent to the connected workflow server."} : "Live execution is active. Operations are being sent to the connected workflow server.",
</p> },
],
},
];
const renderPart = (
part: AgentMessagePart,
index: number,
onApprove?: () => void,
onDeny?: () => void,
) => {
switch (part.type) {
case "text":
return <p key={index}>{part.text}</p>;
case "tool-call":
return (
<div key={index} className="chat-tool-part">
<span>Tool call</span>
<code>{part.call.name}</code>
</div> </div>
);
case "tool-result":
return (
<div key={index} className="chat-tool-part chat-tool-part--result">
<span>Tool result</span>
<code>{part.result.name}</code>
<small>{part.result.status}</small>
</div>
);
case "presentation-action":
return (
<div key={index} className="chat-tool-part chat-tool-part--presentation">
<span>Presentation action</span>
<code>{part.action.type}</code>
</div>
);
case "approval-request":
return (
<div key={index} className="chat-tool-part chat-tool-part--approval">
<span>Approval required</span>
<code>{part.name}</code>
<p>{part.prompt}</p>
<div className="chat-approval-actions">
<button type="button" onClick={onApprove} disabled={!onApprove}>Approve</button>
<button type="button" onClick={onDeny} disabled={!onDeny}>Deny</button>
</div>
</div>
);
case "error":
return <p key={index} className="chat-error">{part.message}</p>;
default:
return null;
}
};
export const OperatorChat = ({ state, messages, onApprove, onDeny }: OperatorChatProps) => {
const visibleMessages = messages && messages.length > 0 ? messages : fallbackMessages(state);
return (
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
{visibleMessages.map((message) => (
<div key={message.id} className={`chat-message chat-message--${message.role === "user" ? "operator" : "system"}`}>
<strong>{message.role === "user" ? "Operator" : "lda.chat"}</strong>
{message.parts.map((part, index) => renderPart(part, index, onApprove, onDeny))}
</div>
))}
</aside> </aside>
); );
};
@@ -55,4 +55,14 @@ describe("PresentationRoute", () => {
await userEvent.click(screen.getByRole("button", { name: /trace evidence/i })); await userEvent.click(screen.getByRole("button", { name: /trace evidence/i }));
expect(await screen.findByText(/workflow.runs.trace/i)).toBeInTheDocument(); expect(await screen.findByText(/workflow.runs.trace/i)).toBeInTheDocument();
}); });
it("runs the prepared agent and applies the interrupt node action", async () => {
render(<PresentationRoute />);
await userEvent.click(screen.getByRole("button", { name: /run prepared agent/i }));
expect(await screen.findByText(/prepared workflow recipe/i)).toBeInTheDocument();
expect(screen.getAllByText(/selectWorkflowNode/i).length).toBeGreaterThanOrEqual(2);
expect(await screen.findByRole("dialog", { name: /issue review/i })).toBeInTheDocument();
});
}); });
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useReducer, useState } from "react"; import { useCallback, useEffect, useMemo, useReducer, useState } from "react";
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import { createPreparedRecipeDriver, assertNever } from "../demo/agent/preparedRecipeDriver.js";
import { useDemoAgent } from "../demo/agent/useDemoAgent.js";
import { loadCanonicalDemoRecording } from "../demo/timeline/replay.js";
import { useDemoTimeline } from "../demo/useDemoTimeline.js"; import { useDemoTimeline } from "../demo/useDemoTimeline.js";
import { hashForBeat } from "./beats.js"; import { hashForBeat } from "./beats.js";
import { PresentationStage } from "./PresentationStage.js"; import { PresentationStage } from "./PresentationStage.js";
@@ -9,6 +12,21 @@ import {
} from "./presentation-state.js"; } from "./presentation-state.js";
import "./presentation.css"; import "./presentation.css";
const projectRecordingToEvidence = (
recording: import("../demo/timeline/models.js").DemoRecording,
): readonly EvidenceRecord[] =>
recording.events
.filter((event) => event.operation !== null)
.map((event) => ({
id: event.id,
operation: event.operation!,
label: event.reason,
equivalentCli: event.equivalentCli ?? "",
request: event.params,
response: event.rawResponse,
durationMs: event.durationMs,
}));
export const PresentationRoute = () => { export const PresentationRoute = () => {
const [state, dispatch] = useReducer( const [state, dispatch] = useReducer(
presentationReducer, presentationReducer,
@@ -16,11 +34,25 @@ export const PresentationRoute = () => {
(initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }), (initial) => presentationReducer(initial, { type: "jump_hash", hash: window.location.hash }),
); );
const recording = useMemo(() => loadCanonicalDemoRecording(), []);
const replayEvidence = useMemo(() => projectRecordingToEvidence(recording), [recording]);
const [evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]); const [evidence, setEvidence] = useState<readonly EvidenceRecord[]>([]);
const recordEvidence = useCallback((record: EvidenceRecord) => { const recordEvidence = useCallback((record: EvidenceRecord) => {
setEvidence((records) => [...records, record]); setEvidence((records) => [...records, record]);
}, []); }, []);
const demo = useDemoTimeline(null, recordEvidence); const demo = useDemoTimeline(null, recordEvidence, recording);
const agentDriver = useMemo(() => createPreparedRecipeDriver(recording), [recording]);
const agent = useDemoAgent(agentDriver);
const handleApprove = useCallback(() => {
agent.submitApproval({ approved: true, comment: "Approved by operator." });
}, [agent]);
const handleDeny = useCallback(() => {
agent.submitApproval({ approved: false, comment: "Denied by operator." });
}, [agent]);
useEffect(() => { useEffect(() => {
const hash = hashForBeat(state.beat); const hash = hashForBeat(state.beat);
@@ -73,18 +105,58 @@ export const PresentationRoute = () => {
dispatch({ type: "set_playback_mode", mode: demo.state.mode }); dispatch({ type: "set_playback_mode", mode: demo.state.mode });
}, [demo.state.mode]); }, [demo.state.mode]);
useEffect(() => {
for (const action of agent.pendingActions) {
switch (action.type) {
case "selectWorkflowNode":
dispatch({ type: "select_node", nodeId: action.nodeId });
break;
case "openEvidence": {
const hasLiveEvidence = evidence.length > 0;
if (!hasLiveEvidence) {
setEvidence(replayEvidence);
}
dispatch({ type: "set_evidence_mode", mode: "open" });
break;
}
case "setBeat":
dispatch({ type: "jump_hash", hash: `#${action.beatId}` });
break;
case "focusOperation":
case "showTraceFrame":
break;
default:
assertNever(action);
}
}
if (agent.pendingActions.length > 0) {
agent.clearPendingActions();
}
}, [agent.pendingActions, agent.clearPendingActions, evidence.length, replayEvidence]);
return ( return (
<main className="presentation-route" aria-label="lda.chat presentation"> <main className="presentation-route" aria-label="lda.chat presentation">
<PresentationStage <PresentationStage
state={state} state={state}
demo={demo} demo={demo}
evidence={evidence} evidence={evidence}
messages={agent.messages}
onApprove={agent.phase === "awaiting-approval" ? handleApprove : undefined}
onDeny={agent.phase === "awaiting-approval" ? handleDeny : undefined}
jump={(beatId) => dispatch({ type: "jump", beat: beatId })} jump={(beatId) => dispatch({ type: "jump", beat: beatId })}
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })} selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
clearNode={() => dispatch({ type: "clear_node" })} clearNode={() => dispatch({ type: "clear_node" })}
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })} openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
closeOverlay={() => dispatch({ type: "close_overlay" })} closeOverlay={() => dispatch({ type: "close_overlay" })}
/> />
<button
type="button"
onClick={() => agent.startPreparedReplay()}
disabled={agent.phase === "running" || agent.phase === "awaiting-approval"}
className="presentation-route__agent-button"
>
Run prepared agent
</button>
</main> </main>
); );
}; };
@@ -1,4 +1,5 @@
import type { EvidenceRecord } from "../app/state.js"; import type { EvidenceRecord } from "../app/state.js";
import type { AgentMessage } from "../demo/agent/events.js";
import { presentationBeats, type BeatId } from "./beats.js"; import { presentationBeats, type BeatId } from "./beats.js";
import { BeatRail } from "./BeatRail.js"; import { BeatRail } from "./BeatRail.js";
import { EvidenceDrawer } from "./EvidenceDrawer.js"; import { EvidenceDrawer } from "./EvidenceDrawer.js";
@@ -15,6 +16,9 @@ type PresentationStageProps = {
readonly state: PresentationState; readonly state: PresentationState;
readonly demo: DemoTimelineController; readonly demo: DemoTimelineController;
readonly evidence: readonly EvidenceRecord[]; readonly evidence: readonly EvidenceRecord[];
readonly messages?: ReadonlyArray<AgentMessage>;
readonly onApprove?: (() => void) | undefined;
readonly onDeny?: (() => void) | undefined;
readonly jump: (beat: BeatId) => void; readonly jump: (beat: BeatId) => void;
readonly selectNode: (nodeId: string) => void; readonly selectNode: (nodeId: string) => void;
readonly clearNode: () => void; readonly clearNode: () => void;
@@ -33,6 +37,9 @@ export const PresentationStage = ({
state, state,
demo, demo,
evidence, evidence,
messages,
onApprove,
onDeny,
jump, jump,
selectNode, selectNode,
clearNode, clearNode,
@@ -48,7 +55,7 @@ export const PresentationStage = ({
return ( return (
<div className="presentation-stage" data-beat={state.beat}> <div className="presentation-stage" data-beat={state.beat}>
<OperatorChat state={state} /> <OperatorChat state={state} messages={messages} onApprove={onApprove} onDeny={onDeny} />
<section className="presentation-stage__main"> <section className="presentation-stage__main">
<header className="presentation-stage__header"> <header className="presentation-stage__header">
<StageCaption eyebrow="lda.chat defense" title={beat.title}> <StageCaption eyebrow="lda.chat defense" title={beat.title}>
@@ -167,3 +167,28 @@
scroll-behavior: auto !important; scroll-behavior: auto !important;
} }
} }
.chat-tool-part {
display: flex;
align-items: center;
gap: 0.45rem;
margin-top: 0.35rem;
padding: 0.35rem 0.45rem;
border: 1px solid color-mix(in oklch, var(--presentation-line), transparent 25%);
border-radius: 0.45rem;
font-size: 0.78rem;
}
.chat-tool-part span {
color: var(--presentation-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chat-tool-part--presentation {
border-color: color-mix(in oklch, var(--presentation-accent), transparent 25%);
}
.chat-error {
color: var(--presentation-red);
}