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