feat: add constrained presentation agent
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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?",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 = (
|
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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user