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 type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||
import { initialDemoTimelineState } from "../timeline/reducer.js";
|
||||
import type { PresentationTargetHealth } from "../../presentation/presentation-target-status.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 = (
|
||||
overrides: Partial<DemoTimelineController> = {},
|
||||
): DemoTimelineController => ({
|
||||
@@ -32,7 +53,7 @@ describe("useTimelineAgent", () => {
|
||||
const start = vi.fn();
|
||||
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());
|
||||
|
||||
expect(start).toHaveBeenCalledWith("live");
|
||||
@@ -47,7 +68,7 @@ describe("useTimelineAgent", () => {
|
||||
const start = vi.fn();
|
||||
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());
|
||||
|
||||
expect(start).toHaveBeenCalledWith("replay");
|
||||
@@ -66,7 +87,7 @@ describe("useTimelineAgent", () => {
|
||||
submitSelectedIssues,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
|
||||
await act(async () => result.current.submitSelectedIssues());
|
||||
|
||||
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
|
||||
@@ -79,7 +100,7 @@ describe("useTimelineAgent", () => {
|
||||
cancelReview,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, "live"));
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
|
||||
await act(async () => result.current.cancelReview());
|
||||
|
||||
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
|
||||
@@ -87,7 +108,7 @@ describe("useTimelineAgent", () => {
|
||||
|
||||
it("disables run when the timeline cannot start", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -100,7 +121,7 @@ describe("useTimelineAgent", () => {
|
||||
next,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, "replay"));
|
||||
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
|
||||
await act(async () => result.current.cancelReview());
|
||||
|
||||
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 type { DemoTimelineController } from "../useDemoTimeline.js";
|
||||
import type { PresentationTargetHealth } from "../../presentation/presentation-target-status.js";
|
||||
import {
|
||||
agentTextMessage,
|
||||
agentToolCallPart,
|
||||
@@ -10,6 +11,11 @@ import type { AgentToolName } from "./tools.js";
|
||||
|
||||
export type TimelineAgentMode = "live" | "replay";
|
||||
|
||||
export type TimelineAgentOptions = {
|
||||
readonly mode: TimelineAgentMode;
|
||||
readonly status: PresentationTargetHealth;
|
||||
};
|
||||
|
||||
export type TimelineAgentController = {
|
||||
readonly messages: ReadonlyArray<AgentMessage>;
|
||||
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 = (
|
||||
demo: DemoTimelineController,
|
||||
modeLabel: TimelineAgentMode,
|
||||
options: TimelineAgentOptions,
|
||||
): TimelineAgentController => {
|
||||
const modeLabel: TimelineAgentMode =
|
||||
options.status.kind === "ready" || options.status.kind === "active"
|
||||
? options.mode
|
||||
: "replay";
|
||||
|
||||
const [messages, setMessages] = useState<ReadonlyArray<AgentMessage>>([
|
||||
agentTextMessage(
|
||||
"timeline-agent-intro",
|
||||
"assistant",
|
||||
modeLabel === "live"
|
||||
? "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.",
|
||||
introForStatus(options.status),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -88,8 +110,6 @@ export const useTimelineAgent = (
|
||||
|
||||
const cancelReview = useCallback(async () => {
|
||||
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") {
|
||||
await demo.next();
|
||||
}
|
||||
@@ -110,4 +130,4 @@ export const useTimelineAgent = (
|
||||
submitSelectedIssues,
|
||||
cancelReview,
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,18 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
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 {
|
||||
observe() {}
|
||||
@@ -56,7 +68,7 @@ describe("PresentationRoute", () => {
|
||||
setReplayMode();
|
||||
const { PresentationRoute } = await import("./PresentationRoute.js");
|
||||
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.getByLabelText("scene position")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -54,10 +54,10 @@ export const PresentationRoute = () => {
|
||||
const presentationTarget = useMemo(() => resolvePresentationTarget(), []);
|
||||
const demo = useDemoTimeline(presentationTarget.target, recordEvidence, recording);
|
||||
const targetStatus = usePresentationTargetStatus(presentationTarget, demo.state);
|
||||
const timelineAgent = useTimelineAgent(
|
||||
demo,
|
||||
presentationTarget.mode === "live" ? "live" : "replay",
|
||||
);
|
||||
const timelineAgent = useTimelineAgent(demo, {
|
||||
mode: presentationTarget.mode === "live" ? "live" : "replay",
|
||||
status: targetStatus,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const hash = hashForLocation(state.location);
|
||||
|
||||
Reference in New Issue
Block a user