fix: make chat honest about live replay status
This commit is contained in:
@@ -2,8 +2,29 @@ import { act, renderHook } from "@testing-library/react";
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||||
import { initialDemoTimelineState } from "../timeline/reducer.js";
|
import { initialDemoTimelineState } from "../timeline/reducer.js";
|
||||||
|
import type { PresentationTargetHealth } from "../../presentation/presentation-target-status.js";
|
||||||
import { useTimelineAgent } from "./timelineAgent.js";
|
import { useTimelineAgent } from "./timelineAgent.js";
|
||||||
|
|
||||||
|
const readyStatus: PresentationTargetHealth = {
|
||||||
|
kind: "ready",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Live target ready",
|
||||||
|
detail: "127.0.0.1:8765",
|
||||||
|
};
|
||||||
|
|
||||||
|
const replayStatus: PresentationTargetHealth = {
|
||||||
|
kind: "replay",
|
||||||
|
label: "Replay evidence",
|
||||||
|
detail: "reviewed recording",
|
||||||
|
};
|
||||||
|
|
||||||
|
const failedStatus: PresentationTargetHealth = {
|
||||||
|
kind: "failed",
|
||||||
|
target: "http://127.0.0.1:8765/rpc",
|
||||||
|
label: "Replay fallback",
|
||||||
|
detail: "connection refused",
|
||||||
|
};
|
||||||
|
|
||||||
const demoController = (
|
const demoController = (
|
||||||
overrides: Partial<DemoTimelineController> = {},
|
overrides: Partial<DemoTimelineController> = {},
|
||||||
): DemoTimelineController => ({
|
): DemoTimelineController => ({
|
||||||
@@ -32,7 +53,7 @@ describe("useTimelineAgent", () => {
|
|||||||
const start = vi.fn();
|
const start = vi.fn();
|
||||||
const demo = demoController({ start });
|
const demo = demoController({ start });
|
||||||
|
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
|
||||||
await act(async () => result.current.runPreparedWorkflow());
|
await act(async () => result.current.runPreparedWorkflow());
|
||||||
|
|
||||||
expect(start).toHaveBeenCalledWith("live");
|
expect(start).toHaveBeenCalledWith("live");
|
||||||
@@ -47,7 +68,7 @@ describe("useTimelineAgent", () => {
|
|||||||
const start = vi.fn();
|
const start = vi.fn();
|
||||||
const demo = demoController({ start });
|
const demo = demoController({ start });
|
||||||
|
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
|
||||||
await act(async () => result.current.runPreparedWorkflow());
|
await act(async () => result.current.runPreparedWorkflow());
|
||||||
|
|
||||||
expect(start).toHaveBeenCalledWith("replay");
|
expect(start).toHaveBeenCalledWith("replay");
|
||||||
@@ -66,7 +87,7 @@ describe("useTimelineAgent", () => {
|
|||||||
submitSelectedIssues,
|
submitSelectedIssues,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
|
||||||
await act(async () => result.current.submitSelectedIssues());
|
await act(async () => result.current.submitSelectedIssues());
|
||||||
|
|
||||||
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
|
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
|
||||||
@@ -79,7 +100,7 @@ describe("useTimelineAgent", () => {
|
|||||||
cancelReview,
|
cancelReview,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
|
||||||
await act(async () => result.current.cancelReview());
|
await act(async () => result.current.cancelReview());
|
||||||
|
|
||||||
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
||||||
@@ -87,7 +108,7 @@ describe("useTimelineAgent", () => {
|
|||||||
|
|
||||||
it("disables run when the timeline cannot start", () => {
|
it("disables run when the timeline cannot start", () => {
|
||||||
const demo = demoController({ canStart: false });
|
const demo = demoController({ canStart: false });
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
|
||||||
expect(result.current.canRun).toBe(false);
|
expect(result.current.canRun).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,7 +121,7 @@ describe("useTimelineAgent", () => {
|
|||||||
next,
|
next,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
|
||||||
await act(async () => result.current.cancelReview());
|
await act(async () => result.current.cancelReview());
|
||||||
|
|
||||||
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
||||||
@@ -111,4 +132,18 @@ describe("useTimelineAgent", () => {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses replay label when live target failed", () => {
|
||||||
|
const demo = demoController();
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTimelineAgent(demo, { mode: "live", status: failedStatus }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current.runLabel).toBe("Run replay walkthrough");
|
||||||
|
expect(result.current.messages[0]?.parts).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
expect.objectContaining({ text: expect.stringMatching(/Replay fallback/i) }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
import type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||||
|
import type { PresentationTargetHealth } from "../../presentation/presentation-target-status.js";
|
||||||
import {
|
import {
|
||||||
agentTextMessage,
|
agentTextMessage,
|
||||||
agentToolCallPart,
|
agentToolCallPart,
|
||||||
@@ -10,6 +11,11 @@ import type { AgentToolName } from "./tools.js";
|
|||||||
|
|
||||||
export type TimelineAgentMode = "live" | "replay";
|
export type TimelineAgentMode = "live" | "replay";
|
||||||
|
|
||||||
|
export type TimelineAgentOptions = {
|
||||||
|
readonly mode: TimelineAgentMode;
|
||||||
|
readonly status: PresentationTargetHealth;
|
||||||
|
};
|
||||||
|
|
||||||
export type TimelineAgentController = {
|
export type TimelineAgentController = {
|
||||||
readonly messages: ReadonlyArray<AgentMessage>;
|
readonly messages: ReadonlyArray<AgentMessage>;
|
||||||
readonly canRun: boolean;
|
readonly canRun: boolean;
|
||||||
@@ -39,17 +45,33 @@ const appendToolMessage = (
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const introForStatus = (status: PresentationTargetHealth): string => {
|
||||||
|
switch (status.kind) {
|
||||||
|
case "ready":
|
||||||
|
return "Live target is ready. Direct slides still show replay evidence until I start the live run.";
|
||||||
|
case "active":
|
||||||
|
return "Live run is active. Operations are being sent to the workflow server.";
|
||||||
|
case "failed":
|
||||||
|
return "Replay fallback is active because the live target is unavailable.";
|
||||||
|
default:
|
||||||
|
return "Replay evidence is active. I can walk through the reviewed recording.";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const useTimelineAgent = (
|
export const useTimelineAgent = (
|
||||||
demo: DemoTimelineController,
|
demo: DemoTimelineController,
|
||||||
modeLabel: TimelineAgentMode,
|
options: TimelineAgentOptions,
|
||||||
): TimelineAgentController => {
|
): TimelineAgentController => {
|
||||||
|
const modeLabel: TimelineAgentMode =
|
||||||
|
options.status.kind === "ready" || options.status.kind === "active"
|
||||||
|
? options.mode
|
||||||
|
: "replay";
|
||||||
|
|
||||||
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([
|
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([
|
||||||
agentTextMessage(
|
agentTextMessage(
|
||||||
"timeline-agent-intro",
|
"timeline-agent-intro",
|
||||||
"assistant",
|
"assistant",
|
||||||
modeLabel === "live"
|
introForStatus(options.status),
|
||||||
? "Live workflow target is configured. I can run the prepared workflow now."
|
|
||||||
: "Replay fallback is active. I can still walk through the prepared workflow evidence.",
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -88,8 +110,6 @@ export const useTimelineAgent = (
|
|||||||
|
|
||||||
const cancelReview = useCallback(async () => {
|
const cancelReview = useCallback(async () => {
|
||||||
await demo.cancelReview("Cancelled by operator.");
|
await demo.cancelReview("Cancelled by operator.");
|
||||||
// In replay the canonical recording only contains the submitted branch.
|
|
||||||
// Do not call next() or the UI would falsely advance into submitted evidence.
|
|
||||||
if (demo.state.mode === "live") {
|
if (demo.state.mode === "live") {
|
||||||
await demo.next();
|
await demo.next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("../connection/api.js", () => ({
|
||||||
|
callOperation: vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
operation: "workflow.health",
|
||||||
|
label: "Health",
|
||||||
|
interpreted: { status: "ok" },
|
||||||
|
exchange: { request: {}, response: {} },
|
||||||
|
equivalentCli: "uv run wf status",
|
||||||
|
durationMs: 2,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
class MockResizeObserver {
|
class MockResizeObserver {
|
||||||
observe() {}
|
observe() {}
|
||||||
@@ -56,7 +68,7 @@ describe("PresentationRoute", () => {
|
|||||||
setReplayMode();
|
setReplayMode();
|
||||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||||
render(<PresentationRoute />);
|
render(<PresentationRoute />);
|
||||||
expect(screen.getByText(/replay fallback is active/i)).toBeInTheDocument();
|
expect(screen.getByText(/replay evidence is active/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
|
expect(screen.queryByLabelText(/presentation scene rail/i)).not.toBeInTheDocument();
|
||||||
expect(screen.getByLabelText("scene position")).toBeInTheDocument();
|
expect(screen.getByLabelText("scene position")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,10 +54,10 @@ export const PresentationRoute = () => {
|
|||||||
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||||
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
||||||
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
|
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
|
||||||
const timelineAgent = useTimelineAgent(
|
const timelineAgent = useTimelineAgent(demo, {
|
||||||
demo,
|
mode: presentationTarget.mode === "live" ? "live" : "replay",
|
||||||
presentationTarget.mode === "live" ? "live" : "replay",
|
status: targetStatus,
|
||||||
);
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const hash = hashForLocation(state.location);
|
const hash = hashForLocation(state.location);
|
||||||
|
|||||||
Reference in New Issue
Block a user