fix: resume workflow on revision requests

This commit is contained in:
lda
2026-07-11 23:04:47 +07:00 Verified
parent 31fb170dba
commit d99447ebd7
30 changed files with 411 additions and 187 deletions
@@ -27,7 +27,7 @@ const baseController = {
play: vi.fn(),
next: vi.fn(),
submitSelectedIssues: vi.fn(),
cancelReview: vi.fn(),
requestRevision: vi.fn(),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
};
@@ -115,8 +115,8 @@ export const LdaReportDemoPanel = ({ controller }: { readonly controller: DemoTi
Resume and create selected issues
</button>
)}
<button onClick={() => void controller.cancelReview(comment)}>
Cancel review
<button onClick={() => void controller.requestRevision(comment)}>
Request revision
</button>
</div>
{state.mode === "replay" && (
@@ -49,7 +49,7 @@ const demoController = (
play: vi.fn(),
next: vi.fn(async () => {}),
submitSelectedIssues: vi.fn(async () => {}),
cancelReview: vi.fn(async () => {}),
requestRevision: vi.fn(async () => {}),
restart: vi.fn(),
primeReplayToStage: vi.fn(),
...overrides,
@@ -128,17 +128,17 @@ describe("useTimelineAgent", () => {
expect(submitSelectedIssues).toHaveBeenCalledWith(["risk-1"], "Create the selected issue.");
});
it("cancels review through the timeline", async () => {
const cancelReview = vi.fn(async () => {});
it("requests revision through the timeline", async () => {
const requestRevision = vi.fn(async () => {});
const demo = demoController({
state: { ...initialDemoTimelineState, phase: "review" },
cancelReview,
requestRevision,
});
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
await act(async () => result.current.cancelReview());
await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
});
it("disables run when the timeline cannot start", () => {
@@ -147,19 +147,19 @@ describe("useTimelineAgent", () => {
expect(result.current.canRun).toBe(false);
});
it("does not advance replay cancellation into the submitted recording branch", async () => {
const cancelReview = vi.fn(async () => {});
it("advances replay revision requests through the negative branch", async () => {
const requestRevision = vi.fn(async () => {});
const next = vi.fn(async () => {});
const demo = demoController({
state: { ...initialDemoTimelineState, mode: "replay", phase: "review" },
cancelReview,
requestRevision,
next,
});
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "replay", status: replayStatus }));
await act(async () => result.current.cancelReview());
await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
expect(next).not.toHaveBeenCalled();
expect(result.current.messages.at(-1)?.parts).toEqual(
expect.arrayContaining([
@@ -168,20 +168,20 @@ describe("useTimelineAgent", () => {
);
});
it("live cancel does not call next", async () => {
const cancelReview = vi.fn(async () => {});
it("live revision request advances the timeline", async () => {
const requestRevision = vi.fn(async () => {});
const next = vi.fn(async () => {});
const demo = demoController({
state: { ...initialDemoTimelineState, mode: "live", phase: "review" },
cancelReview,
requestRevision,
next,
});
const { result } = renderHook(() => useTimelineAgent(demo, { mode: "live", status: readyStatus }));
await act(async () => result.current.cancelReview());
await act(async () => result.current.requestRevision());
expect(cancelReview).toHaveBeenCalledWith("Cancelled by operator.");
expect(next).not.toHaveBeenCalled();
expect(requestRevision).toHaveBeenCalledWith("Request revisions before creating issues.");
expect(next).toHaveBeenCalledOnce();
});
it("uses replay label when live target failed", () => {
@@ -22,7 +22,7 @@ export type TimelineAgentController = {
readonly runLabel: string;
readonly runPreparedWorkflow: () => Promise<void>;
readonly submitSelectedIssues: () => Promise<void>;
readonly cancelReview: () => Promise<void>;
readonly requestRevision: () => Promise<void>;
};
const DEFAULT_COMMENT = "Create the selected issue.";
@@ -120,16 +120,15 @@ export const useTimelineAgent = (
));
}, [demo, selectedIssueIds]);
const cancelReview = useCallback(async () => {
await demo.cancelReview("Cancelled by operator.");
// Cancellation is terminal in presentation mode. Do not call next() or the UI
// would falsely advance into submitted/resume evidence.
const requestRevision = useCallback(async () => {
await demo.requestRevision("Request revisions before creating issues.");
if (modeLabel === "live") await demo.next();
setMessages((current) => appendToolMessage(
current,
"timeline-agent-cancel",
"timeline-agent-revision",
"resumeIssueReview",
{},
{ outcome: "cancelled" },
{ approved: false, outcome: "cancelled" },
{ outcome: "cancelled", label: "revision requested" },
));
}, [demo]);
@@ -139,6 +138,6 @@ export const useTimelineAgent = (
runLabel,
runPreparedWorkflow,
submitSelectedIssues,
cancelReview,
requestRevision,
};
};
@@ -110,7 +110,7 @@ describe("demoTimelineReducer", () => {
expect(restarted.appliedCount).toBe(0);
});
it("cancels review as a terminal non-autoplay phase", () => {
it("resumes review autoplay", () => {
const reviewing = {
...initialDemoTimelineState,
mode: "replay" as const,
@@ -119,10 +119,10 @@ describe("demoTimelineReducer", () => {
appliedCount: 1,
autoplay: false,
};
const cancelled = demoTimelineReducer(reviewing, { type: "cancel_review" });
const continued = demoTimelineReducer(reviewing, { type: "continue_review" });
expect(cancelled.phase).toBe("cancelled");
expect(cancelled.autoplay).toBe(false);
expect(cancelled.appliedCount).toBe(1);
expect(continued.phase).toBe("running");
expect(continued.autoplay).toBe(true);
expect(continued.appliedCount).toBe(1);
});
});
@@ -36,7 +36,6 @@ export type DemoTimelineAction =
| { readonly type: "pause" }
| { readonly type: "play" }
| { readonly type: "continue_review" }
| { readonly type: "cancel_review" }
| { readonly type: "fail"; readonly message: string; readonly event?: DemoEvent }
| { readonly type: "restart" }
| {
@@ -104,10 +103,6 @@ export const demoTimelineReducer = (
return state.phase === "review"
? { ...state, phase: "running", autoplay: true }
: state;
case "cancel_review":
return state.phase === "review"
? { ...state, phase: "cancelled", autoplay: false }
: state;
case "fail":
return {
...state,
@@ -1,5 +1,9 @@
import { describe, expect, it, vi } from "vitest";
import { loadCanonicalDemoRecording, nextReplayEvent } from "./replay.js";
import {
loadCanonicalDemoRecording,
nextReplayEvent,
revisionReplayRecording,
} from "./replay.js";
vi.mock("../../connection/api.js", () => ({
callOperation: vi.fn(() => {
@@ -27,4 +31,24 @@ describe("canonical demo recording", () => {
expect(nextReplayEvent(recording, 0)?.stage).toBe("deployment_check");
expect(nextReplayEvent(recording, recording.events.length)).toBeNull();
});
it("projects a truthful revision-requested branch", () => {
const recording = revisionReplayRecording(loadCanonicalDemoRecording());
const resume = recording.events.find((event) => event.stage === "run_resume");
const completed = recording.events.find((event) => event.stage === "completed");
expect(recording.recordingId).toBe("lda-report-revision-v1");
expect(resume?.params).toMatchObject({
resume_outcome: "cancelled",
resume_payload: { approved: false, selected_issue_ids: [] },
});
expect((resume?.interpreted as { output: { approved: boolean; created_issues: unknown[] } }).output).toEqual({
approved: false,
markdown: "# Revision Requested\n\nRequest revisions before creating issues.",
created_issues: [],
selected_issue_ids: [],
comment: "Request revisions before creating issues.",
});
expect((completed?.interpreted as { trace: { frames: unknown[] } }).trace.frames).toHaveLength(9);
});
});
@@ -1,6 +1,148 @@
import recordingText from "../recordings/lda-report-success.v1.json?raw";
import { decodeDemoRecording, type DemoEvent, type DemoRecording } from "./models.js";
export const REVISION_REQUEST_COMMENT = "Request revisions before creating issues.";
const revisionRunId = "run_recorded_lda_report_revision";
const revisionOutput = {
approved: false,
markdown: "# Revision Requested\n\nRequest revisions before creating issues.",
created_issues: [],
selected_issue_ids: [],
comment: REVISION_REQUEST_COMMENT,
};
const revisionTrace = {
frames: [
"reset_board",
"read_docs",
"analyze",
"build_report",
"draft_issues",
"review_issues",
"review_issues",
"revision_requested",
"end_cancelled",
].map((nodeId, index) => ({
nodeId,
stepType: nodeId === "end_cancelled" ? "end" : nodeId === "review_issues" && index === 6 ? "interrupt" : "node",
outcome: nodeId === "review_issues" && index === 5 ? "interrupt" : nodeId === "end_cancelled" ? "cancelled" : "ok",
resolvedInput: {},
output: {},
stateChanges: {},
})),
traceStart: 0,
traceLimit: 50,
traceTruncated: false,
};
/**
* Projects the real negative workflow outcome into the deterministic replay.
* The success recording is still the source for discovery and interruption;
* only the post-decision branch is replaced with facts captured from RPC.
*/
export const revisionReplayRecording = (recording: DemoRecording): DemoRecording => {
const events = recording.events.map((event) => {
const resultingIds = { ...event.resultingIds, runId: event.stage === "deployment_check" ? null : revisionRunId };
if (event.stage === "run_start") {
const interpreted = event.interpreted as Record<string, unknown>;
return {
...event,
resultingIds,
interpreted: { ...interpreted, runId: revisionRunId },
rawResponse: { result: { run_id: revisionRunId, status: "interrupted" } },
};
}
if (event.stage === "interrupt") return { ...event, resultingIds };
if (event.stage === "run_resume") {
return {
...event,
id: "revision-3-run-resume",
reason: "Resume the interrupted run with revision requested.",
resultingIds,
equivalentCli: `uv run wf run resume ${revisionRunId} --payload '<json>'`,
params: {
run_id: revisionRunId,
resume_payload: {
approved: false,
selected_issue_ids: [],
comment: REVISION_REQUEST_COMMENT,
},
resume_outcome: "cancelled",
trace_range: { start: 0, limit: 50 },
},
rawResponse: {
result: {
run_id: revisionRunId,
status: "completed",
outcome: "cancelled",
output: revisionOutput,
trace_count: revisionTrace.frames.length,
},
},
interpreted: {
runId: revisionRunId,
deploymentId: recording.deploymentId,
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "cancelled",
error: null,
output: revisionOutput,
diagnostics: [],
traceCount: revisionTrace.frames.length,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed after revision was requested.",
patchExamples: [],
warnings: [],
},
},
};
}
if (event.stage === "trace_read") {
return {
...event,
id: "revision-4-trace-read",
reason: "Read the revision-requested run trace.",
resultingIds,
params: { run_id: revisionRunId, trace_range: { start: 0, limit: 50 } },
rawResponse: {
result: {
run_id: revisionRunId,
status: "completed",
trace_count: revisionTrace.frames.length,
trace: revisionTrace.frames,
},
},
interpreted: { runId: revisionRunId, status: "completed", ...revisionTrace },
};
}
if (event.stage === "completed") {
return {
...event,
id: "revision-5-completed",
reason: "The revision-requested report workflow completed.",
resultingIds,
interpreted: { output: revisionOutput, trace: revisionTrace },
};
}
return { ...event, resultingIds };
});
return decodeDemoRecording({
...recording,
recordingId: "lda-report-revision-v1",
title: "lda.chat report workflow revision requested",
events,
});
};
export const loadCanonicalDemoRecording = (): DemoRecording => {
let parsed: unknown;
try {
@@ -403,7 +403,7 @@ describe("useDemoTimeline", () => {
expect(result.current.trace?.frames.length).toBeGreaterThan(0);
});
it("replay cancellation stops without consuming submitted branch", async () => {
it("replay revision request resumes through the negative branch", async () => {
vi.useFakeTimers();
const { result } = renderHook(() => useDemoTimeline(null, vi.fn()));
act(() => result.current.setMode("replay"));
@@ -412,12 +412,13 @@ describe("useDemoTimeline", () => {
await act(async () => vi.advanceTimersByTimeAsync(900));
}
await act(async () => result.current.cancelReview("Cancelled."));
await act(async () => vi.advanceTimersByTimeAsync(1800));
await act(async () => result.current.requestRevision("Request revisions."));
expect(result.current.state.phase).toBe("cancelled");
expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("interrupt");
expect(result.current.output).toBeNull();
expect(result.current.state.phase).toBe("paused");
expect(result.current.state.events[result.current.state.appliedCount - 1]?.stage).toBe("run_resume");
expect(result.current.output?.approved).toBe(false);
expect(result.current.output?.created_issues).toHaveLength(0);
expect(result.current.trace).toBeNull();
});
it("missingDeploymentMessage shows when live mode with null target", () => {
@@ -474,7 +475,7 @@ describe("useDemoTimeline", () => {
expect(result.current.interruptPayload).toBeNull();
});
it("live cancellation is terminal and does not advance", async () => {
it("live revision request resumes through the negative branch", async () => {
vi.useFakeTimers();
mockedCallOperation
.mockResolvedValueOnce({
@@ -533,6 +534,41 @@ describe("useDemoTimeline", () => {
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run start lda_report_case_study.default --input '<json>'",
durationMs: 88,
})
.mockResolvedValueOnce({
ok: true,
operation: "workflow.runs.resume" as const,
label: "Resume run",
interpreted: {
runId: "run_demo",
deploymentId: "lda_report_case_study.default",
artifactId: "lda_report_case_study",
artifactVersion: 1,
status: "completed",
resumeReadiness: "not_applicable",
interrupt: null,
outcome: "cancelled",
error: null,
output: {
approved: false,
markdown: "# Revision Requested",
created_issues: [],
selected_issue_ids: [],
},
diagnostics: [],
traceCount: 9,
nextActions: {
canContinue: false,
canSaveNow: null,
recommendedNextTool: null,
reason: "Run completed after revision was requested.",
patchExamples: [],
warnings: [],
},
},
exchange: { request: {}, response: {} },
equivalentCli: "uv run wf run resume run_demo --payload '<json>'",
durationMs: 88,
});
const { result } = renderHook(() =>
@@ -543,12 +579,12 @@ describe("useDemoTimeline", () => {
await act(async () => vi.advanceTimersByTimeAsync(900));
expect(result.current.state.phase).toBe("review");
await act(async () => result.current.cancelReview("Cancelled."));
await act(async () => vi.advanceTimersByTimeAsync(1800));
await act(async () => result.current.requestRevision("Request revisions."));
await act(async () => result.current.next());
expect(result.current.state.phase).toBe("cancelled");
expect(result.current.output).toBeNull();
expect(result.current.trace).toBeNull();
expect(mockedCallOperation).toHaveBeenCalledTimes(2);
expect(result.current.state.phase).toBe("paused");
expect(result.current.output?.approved).toBe(false);
expect(result.current.output?.created_issues).toHaveLength(0);
expect(mockedCallOperation).toHaveBeenCalledTimes(3);
});
});
+30 -9
View File
@@ -21,7 +21,7 @@ import {
type DemoApproval,
type LiveDemoContext,
} from "./timeline/live.js";
import { loadCanonicalDemoRecording } from "./timeline/replay.js";
import { loadCanonicalDemoRecording, revisionReplayRecording } from "./timeline/replay.js";
type EvidenceRecorder = (record: EvidenceRecord) => void;
@@ -43,7 +43,7 @@ export type DemoTimelineController = {
selectedIssueIds: ReadonlyArray<string>,
comment: string,
) => Promise<void>;
readonly cancelReview: (comment: string) => Promise<void>;
readonly requestRevision: (comment: string) => Promise<void>;
readonly restart: () => void;
readonly primeReplayToStage: (stage: DemoEvent["stage"] | null) => void;
};
@@ -86,10 +86,11 @@ export const useDemoTimeline = (
const generationRef = useRef(0);
const [inFlight, setInFlight] = useState(false);
const approvalRef = useRef<DemoApproval | null>(null);
const activeRecording = useRef<DemoRecording | null>(recording ?? null);
if (activeRecording.current === null) {
activeRecording.current = loadCanonicalDemoRecording();
const canonicalRecording = useRef<DemoRecording | null>(recording ?? null);
if (canonicalRecording.current === null) {
canonicalRecording.current = loadCanonicalDemoRecording();
}
const activeRecording = useRef<DemoRecording | null>(canonicalRecording.current);
const [interruptPayload, setInterruptPayload] = useState<LdaReportInterruptPayload | null>(null);
const [output, setOutput] = useState<LdaReportOutput | null>(null);
@@ -101,6 +102,7 @@ export const useDemoTimeline = (
setInFlight(false);
liveContextRef.current = initialLiveDemoContext;
approvalRef.current = null;
activeRecording.current = canonicalRecording.current;
setInterruptPayload(null);
setOutput(null);
setTrace(null);
@@ -285,15 +287,32 @@ export const useDemoTimeline = (
dispatch({ type: "continue_review" });
}, []);
const cancelReview = useCallback(async (comment: string) => {
const requestRevision = useCallback(async (comment: string) => {
approvalRef.current = {
approved: false,
selectedIssueIds: [],
comment,
outcome: "cancelled",
};
dispatch({ type: "cancel_review" });
}, []);
if (target === null) {
const recording = activeRecording.current;
if (recording) {
const revisionRecording = revisionReplayRecording(recording);
const appliedCount = appliedCountForStage(revisionRecording.events, "run_resume");
resetRuntime();
activeRecording.current = revisionRecording;
projectTransientState(revisionRecording.events, appliedCount);
dispatch({
type: "prime_replay",
events: revisionRecording.events,
appliedCount,
phase: "paused",
});
}
return;
}
dispatch({ type: "continue_review" });
}, [projectTransientState, resetRuntime, target]);
const restart = useCallback(() => {
resetRuntime();
@@ -302,6 +321,8 @@ export const useDemoTimeline = (
const primeReplayToStage = useCallback((stage: DemoEvent["stage"] | null) => {
if (stage === null || state.mode !== "replay") return;
const isRevisionBranch = state.events.some((event) => event.id === "revision-3-run-resume");
if (isRevisionBranch) return;
const recording = activeRecording.current;
if (!recording) return;
const appliedCount = appliedCountForStage(recording.events, stage);
@@ -334,7 +355,7 @@ export const useDemoTimeline = (
play,
next,
submitSelectedIssues,
cancelReview,
requestRevision,
restart,
primeReplayToStage,
};