feat: add timeline backed demo agent
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||||
|
import { initialDemoTimelineState } from "../timeline/reducer.js";
|
||||||
|
import { useTimelineAgent } from "./timelineAgent.js";
|
||||||
|
|
||||||
|
const demoController = (
|
||||||
|
overrides: Partial<DemoTimelineController> = {},
|
||||||
|
): DemoTimelineController => ({
|
||||||
|
state: initialDemoTimelineState,
|
||||||
|
inFlight: false,
|
||||||
|
interruptPayload: null,
|
||||||
|
output: null,
|
||||||
|
trace: null,
|
||||||
|
missingDeploymentMessage: null,
|
||||||
|
recordingId: null,
|
||||||
|
canStart: true,
|
||||||
|
setMode: vi.fn(),
|
||||||
|
start: vi.fn(),
|
||||||
|
pause: vi.fn(),
|
||||||
|
play: vi.fn(),
|
||||||
|
next: vi.fn(async () => {}),
|
||||||
|
submitSelectedIssues: vi.fn(async () => {}),
|
||||||
|
cancelReview: vi.fn(async () => {}),
|
||||||
|
restart: vi.fn(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useTimelineAgent", () => {
|
||||||
|
it("starts the prepared workflow through the timeline", async () => {
|
||||||
|
const start = vi.fn();
|
||||||
|
const demo = demoController({ start });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||||
|
await act(async () => result.current.runPreparedWorkflow());
|
||||||
|
|
||||||
|
expect(start).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.messages.at(-1)?.parts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ type: "tool-result" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("submits selected issues from the current interrupt payload", async () => {
|
||||||
|
const submitSelectedIssues = vi.fn(async () => {});
|
||||||
|
const demo = demoController({
|
||||||
|
state: { ...initialDemoTimelineState, phase: "review" },
|
||||||
|
interruptPayload: {
|
||||||
|
report_markdown: "# Report",
|
||||||
|
proposed_issues: [
|
||||||
|
{ id: "risk-1", title: "Risk", body: "Body", severity: "medium" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
submitSelectedIssues,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
||||||
|
await act(async () => result.current.submitSelectedIssues());
|
||||||
|
|
||||||
|
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels review through the timeline", async () => {
|
||||||
|
const cancelReview = vi.fn(async () => {});
|
||||||
|
const demo = demoController({
|
||||||
|
state: { ...initialDemoTimelineState, phase: "review" },
|
||||||
|
cancelReview,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||||
|
await act(async () => result.current.cancelReview());
|
||||||
|
|
||||||
|
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables run when the timeline cannot start", () => {
|
||||||
|
const demo = demoController({ canStart: false });
|
||||||
|
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||||
|
expect(result.current.canRun).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||||
|
import {
|
||||||
|
agentTextMessage,
|
||||||
|
agentToolCallPart,
|
||||||
|
agentToolResultPart,
|
||||||
|
type AgentMessage,
|
||||||
|
} from "./events.js";
|
||||||
|
import type { AgentToolName } from "./tools.js";
|
||||||
|
|
||||||
|
export type TimelineAgentMode = "live" | "replay";
|
||||||
|
|
||||||
|
export type TimelineAgentController = {
|
||||||
|
readonly messages: ReadonlyArray<AgentMessage>;
|
||||||
|
readonly canRun: boolean;
|
||||||
|
readonly runLabel: string;
|
||||||
|
readonly runPreparedWorkflow: () => Promise<void>;
|
||||||
|
readonly submitSelectedIssues: () => Promise<void>;
|
||||||
|
readonly cancelReview: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_COMMENT = "Create the selected issue.";
|
||||||
|
|
||||||
|
const appendToolMessage = (
|
||||||
|
messages: ReadonlyArray<AgentMessage>,
|
||||||
|
id: string,
|
||||||
|
name: AgentToolName,
|
||||||
|
input: unknown,
|
||||||
|
output: unknown,
|
||||||
|
): ReadonlyArray<AgentMessage> => [
|
||||||
|
...messages,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
role: "assistant",
|
||||||
|
parts: [
|
||||||
|
agentToolCallPart(`${id}-call`, name, input),
|
||||||
|
agentToolResultPart(`${id}-call`, name, "success", output),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const useTimelineAgent = (
|
||||||
|
demo: DemoTimelineController,
|
||||||
|
modeLabel: TimelineAgentMode,
|
||||||
|
): TimelineAgentController => {
|
||||||
|
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([
|
||||||
|
agentTextMessage(
|
||||||
|
"timeline-agent-intro",
|
||||||
|
"assistant",
|
||||||
|
modeLabel === "live"
|
||||||
|
? "Live workflow server is available. I can run the prepared workflow now."
|
||||||
|
: "Replay fallback is active. I can still walk through the prepared workflow evidence.",
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runLabel = modeLabel === "live" ? "Run prepared workflow" : "Run replay walkthrough";
|
||||||
|
const canRun = demo.canStart && !demo.inFlight && demo.state.phase !== "running";
|
||||||
|
|
||||||
|
const runPreparedWorkflow = useCallback(async () => {
|
||||||
|
if (!demo.canStart || demo.inFlight) return;
|
||||||
|
demo.restart();
|
||||||
|
demo.start();
|
||||||
|
setMessages((current) => appendToolMessage(
|
||||||
|
current,
|
||||||
|
"timeline-agent-start",
|
||||||
|
"startPreparedReportRun",
|
||||||
|
{ mode: modeLabel },
|
||||||
|
{ phase: "started" },
|
||||||
|
));
|
||||||
|
}, [demo, modeLabel]);
|
||||||
|
|
||||||
|
const selectedIssueIds = useMemo(
|
||||||
|
() => demo.interruptPayload?.proposed_issues.map((issue) => issue.id) ?? [],
|
||||||
|
[demo.interruptPayload],
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitSelectedIssues = useCallback(async () => {
|
||||||
|
if (selectedIssueIds.length === 0) return;
|
||||||
|
await demo.submitSelectedIssues(selectedIssueIds, DEFAULT_COMMENT);
|
||||||
|
await demo.next();
|
||||||
|
setMessages((current) => appendToolMessage(
|
||||||
|
current,
|
||||||
|
"timeline-agent-submit",
|
||||||
|
"resumeIssueReview",
|
||||||
|
{ selectedIssueIds },
|
||||||
|
{ outcome: "submitted" },
|
||||||
|
));
|
||||||
|
}, [demo, selectedIssueIds]);
|
||||||
|
|
||||||
|
const cancelReview = useCallback(async () => {
|
||||||
|
await demo.cancelReview("Cancelled by operator.");
|
||||||
|
await demo.next();
|
||||||
|
setMessages((current) => appendToolMessage(
|
||||||
|
current,
|
||||||
|
"timeline-agent-cancel",
|
||||||
|
"resumeIssueReview",
|
||||||
|
{},
|
||||||
|
{ outcome: "cancelled" },
|
||||||
|
));
|
||||||
|
}, [demo]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
canRun,
|
||||||
|
runLabel,
|
||||||
|
runPreparedWorkflow,
|
||||||
|
submitSelectedIssues,
|
||||||
|
cancelReview,
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user