fix: make chat honest about live replay status

This commit is contained in:
lda
2026-07-09 16:01:57 +07:00 Verified
parent e7fb4cc087
commit d09e12d3ad
4 changed files with 86 additions and 19 deletions
@@ -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);