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 { 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();
} }
@@ -110,4 +130,4 @@ export const useTimelineAgent = (
submitSelectedIssues, submitSelectedIssues,
cancelReview, cancelReview,
}; };
}; };
@@ -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);