feat: add constrained presentation agent
This commit is contained in:
@@ -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";
|
||||
|
||||
type OperatorChatProps = {
|
||||
readonly state: PresentationState;
|
||||
readonly messages?: ReadonlyArray<AgentMessage> | undefined;
|
||||
readonly onApprove?: (() => void) | undefined;
|
||||
readonly onDeny?: (() => void) | undefined;
|
||||
};
|
||||
|
||||
export const OperatorChat = ({ state }: OperatorChatProps) => (
|
||||
<aside className="operator-chat" data-mode={state.chatMode} aria-label="scripted operator chat">
|
||||
<div className="chat-message chat-message--operator">
|
||||
<strong>Operator</strong>
|
||||
<p>Prepare the thesis readiness report.</p>
|
||||
</div>
|
||||
<div className="chat-message chat-message--system">
|
||||
<strong>lda.chat</strong>
|
||||
<p>Found prepared workflow recipe: <code>lda_report_case_study</code>.</p>
|
||||
</div>
|
||||
<div className="chat-message chat-message--system">
|
||||
<strong>lda.chat</strong>
|
||||
<p>
|
||||
{state.playbackMode === "replay"
|
||||
const fallbackMessages = (state: PresentationState): ReadonlyArray<AgentMessage> => [
|
||||
{ id: "fallback-user", role: "user", parts: [{ type: "text", text: PREPARE_THESIS_REPORT_RECIPE.userPrompt }] },
|
||||
{
|
||||
id: "fallback-system",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "text", text: `Found prepared workflow recipe: ${PREPARE_THESIS_REPORT_RECIPE.id}.` },
|
||||
{
|
||||
type: "text",
|
||||
text: state.playbackMode === "replay"
|
||||
? "Replay mode is active. Live execution is available when connected."
|
||||
: "Live execution is active. Operations are being sent to the connected workflow server."}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
: "Live execution is active. Operations are being sent to the connected workflow server.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -55,4 +55,14 @@ describe("PresentationRoute", () => {
|
||||
await userEvent.click(screen.getByRole("button", { name: /trace evidence/i }));
|
||||
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 { 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 { hashForBeat } from "./beats.js";
|
||||
import { PresentationStage } from "./PresentationStage.js";
|
||||
@@ -9,6 +12,21 @@ import {
|
||||
} from "./presentation-state.js";
|
||||
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 = () => {
|
||||
const [state, dispatch] = useReducer(
|
||||
presentationReducer,
|
||||
@@ -16,11 +34,25 @@ export const PresentationRoute = () => {
|
||||
(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 recordEvidence = useCallback((record: EvidenceRecord) => {
|
||||
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(() => {
|
||||
const hash = hashForBeat(state.beat);
|
||||
@@ -73,18 +105,58 @@ export const PresentationRoute = () => {
|
||||
dispatch({ type: "set_playback_mode", 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 (
|
||||
<main className="presentation-route" aria-label="lda.chat presentation">
|
||||
<PresentationStage
|
||||
state={state}
|
||||
demo={demo}
|
||||
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 })}
|
||||
selectNode={(nodeId) => dispatch({ type: "select_node", nodeId })}
|
||||
clearNode={() => dispatch({ type: "clear_node" })}
|
||||
openEvidence={() => dispatch({ type: "set_evidence_mode", mode: "open" })}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { EvidenceRecord } from "../app/state.js";
|
||||
import type { AgentMessage } from "../demo/agent/events.js";
|
||||
import { presentationBeats, type BeatId } from "./beats.js";
|
||||
import { BeatRail } from "./BeatRail.js";
|
||||
import { EvidenceDrawer } from "./EvidenceDrawer.js";
|
||||
@@ -15,6 +16,9 @@ type PresentationStageProps = {
|
||||
readonly state: PresentationState;
|
||||
readonly demo: DemoTimelineController;
|
||||
readonly evidence: readonly EvidenceRecord[];
|
||||
readonly messages?: ReadonlyArray<AgentMessage>;
|
||||
readonly onApprove?: (() => void) | undefined;
|
||||
readonly onDeny?: (() => void) | undefined;
|
||||
readonly jump: (beat: BeatId) => void;
|
||||
readonly selectNode: (nodeId: string) => void;
|
||||
readonly clearNode: () => void;
|
||||
@@ -33,6 +37,9 @@ export const PresentationStage = ({
|
||||
state,
|
||||
demo,
|
||||
evidence,
|
||||
messages,
|
||||
onApprove,
|
||||
onDeny,
|
||||
jump,
|
||||
selectNode,
|
||||
clearNode,
|
||||
@@ -48,7 +55,7 @@ export const PresentationStage = ({
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header className="presentation-stage__header">
|
||||
<StageCaption eyebrow="lda.chat defense" title={beat.title}>
|
||||
|
||||
@@ -167,3 +167,28 @@
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user